diff --git a/src/cli/commands/profile.ts b/src/cli/commands/profile.ts index 4d528a2..d1fc276 100644 --- a/src/cli/commands/profile.ts +++ b/src/cli/commands/profile.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { + baseConfig, extractProfileSnapshot, getProfileSnapshot, listProfiles, @@ -119,7 +120,12 @@ export function applyProfileAction( } if (action === "save") { - const snapshot = extractProfileSnapshot(resolveEffectiveConfig(config)); + // Snapshot the explicit target, never the active profile. Resolving + // without a name would fall back to activeProfile and stamp the other + // profile's agents into this one. A new name starts from the base setup. + const existing = getProfileSnapshot(config, name); + const source = existing === undefined ? baseConfig(config) : resolveEffectiveConfig(config, name); + const snapshot = extractProfileSnapshot(source); const profiles = { ...(config.profiles ?? {}), [name]: snapshot }; const next: RunAgentConfig = { ...config, profiles }; return { @@ -237,7 +243,7 @@ export function registerProfileCommand(program: Command): void { const named: ReadonlyArray<{ action: Exclude; description: string }> = [ { action: "show", description: "print a saved profile" }, - { action: "save", description: "snapshot the current setup as a profile" }, + { action: "save", description: "snapshot the base setup as a profile (the active profile itself re-saves its own setup)" }, { action: "use", description: "make a profile the active setup" }, { action: "delete", description: "delete a saved profile" }, ]; diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index ca27b59..e69a5bc 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -625,9 +625,19 @@ function watchResize(listener: () => void): () => void { export async function runModelSetupWizard(options: ModelWizardOptions = {}): Promise { const loaded = options.config ?? loadConfig(); const profile = options.profile !== undefined ? parseProfileName(options.profile) : undefined; + // --profile edits one saved setup, shown exactly as saved. A name nobody + // saved yet starts from the base setup with an empty agent map: role + // screens show no inherited "atual", while sandbox/autocompact/orchestrator + // keep mirroring what the profile would inherit at launch (blanking them + // would silently downgrade the base values on confirm). Without --profile + // the base config is edited and the active snapshot is left untouched + // (see runSetupBatch below). + const snapshot = profile === undefined ? undefined : getProfileSnapshot(loaded, profile); const config = profile === undefined ? loaded - : { ...baseConfig(loaded), ...getProfileSnapshot(loaded, profile) }; + : snapshot === undefined + ? { ...baseConfig(loaded), agents: {} } + : { ...baseConfig(loaded), ...snapshot }; if (!(options.isTTY ?? isInteractiveTerminal())) return config; const output = options.output ?? process.stdout; @@ -654,7 +664,7 @@ export async function runModelSetupWizard(options: ModelWizardOptions = {}): Pro // triple harness:model:effort is chosen in one step instead of a second // pass at the end. A second runScreens call is not an option: the picker // owns raw mode for exactly one run per stream set. - const roleScreens = buildScreens(ROLES, harnesses, config.agents ?? {}, config.defaultAgent ?? "claude").map( + const roleScreens = buildScreens(ROLES, harnesses, config.agents ?? {}, loaded.defaultAgent ?? "claude").map( (screen, roleIndex) => ({ ...screen, next: (result: ScreenResult): Screen[] => { @@ -1361,13 +1371,20 @@ export async function runSetupBatch( } const current: RunAgentConfig = { ...DEFAULT_CONFIG, ...(read.config ?? {}) }; - // --profile edits one saved setup instead of the active one. A name nobody - // saved yet starts from the base config, so creating a profile needs no - // separate gesture. + // --profile edits one saved setup instead of the active one. An existing + // name loads base plus its snapshot; a name nobody saved yet starts from + // the base setup with an empty agent map, so root agents never leak into + // the new profile as inherited "atual" while sandbox/autocompact/ + // orchestrator keep their inherited values. Without --profile the base + // config is edited and saved profiles (including the active one) are left + // untouched. const profile = options.profile; + const snapshot = profile === undefined ? undefined : getProfileSnapshot(current, profile); const target: RunAgentConfig = profile === undefined ? current - : { ...baseConfig(current), ...getProfileSnapshot(current, profile) }; + : snapshot === undefined + ? { ...baseConfig(current), agents: {} } + : { ...baseConfig(current), ...snapshot }; const lastByRole = new Map(); options.binds.forEach((binding, index) => lastByRole.set(binding.role, index)); const winning = options.binds.filter((binding, index) => lastByRole.get(binding.role) === index); diff --git a/src/drivers/antigravity/driver.ts b/src/drivers/antigravity/driver.ts index e33694f..54c6d17 100644 --- a/src/drivers/antigravity/driver.ts +++ b/src/drivers/antigravity/driver.ts @@ -1,12 +1,92 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { StringDecoder } from "node:string_decoder"; import { detectBinary, runCommandWithTimeout } from "../helpers.js"; -import type { AgentInstallation, StartOptions } from "../../core/driver.js"; +import type { AgentInstallation, DriverSession, ReattachRequest, StartOptions } from "../../core/driver.js"; import type { AgentCapabilities } from "../../core/capabilities.js"; +import type { AgentEvent } from "../../core/events.js"; import type { ListModelsOptions, ModelInfo, ProviderModels } from "../../core/models.js"; import { createRuntimeHooks, SessionDriver } from "../session-driver.js"; -import { parseAntigravityLine } from "./parser.js"; +import { sessionLogPaths } from "../session-runtime.js"; +import { createAntigravityParser, type AntigravityParser } from "./parser.js"; + +const MAX_REPLAY_READ_BYTES = 64 * 1024; + +export function alignAntigravityLogOffset(stdoutPath: string, logOffset: number): number { + if (!Number.isSafeInteger(logOffset) || logOffset <= 0) return 0; + + let fd: number | undefined; + try { + fd = fs.openSync(stdoutPath, "r"); + const size = fs.fstatSync(fd).size; + if (logOffset > size) return 0; + let position = logOffset; + const buffer = Buffer.allocUnsafe(MAX_REPLAY_READ_BYTES); + while (position > 0) { + const start = Math.max(0, position - MAX_REPLAY_READ_BYTES); + const bytesRead = fs.readSync(fd, buffer, 0, position - start, start); + if (bytesRead === 0) return 0; + const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a); + if (newline >= 0) return start + newline + 1; + position = start; + } + return 0; + } catch { + return 0; + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} + +export function replayAntigravityOutput( + stdoutPath: string, + logOffset: number, + parser: AntigravityParser, + sessionId: string, +): void { + if (!Number.isSafeInteger(logOffset) || logOffset <= 0) return; + + let fd: number | undefined; + try { + fd = fs.openSync(stdoutPath, "r"); + const buffer = Buffer.allocUnsafe(Math.min(MAX_REPLAY_READ_BYTES, logOffset)); + const decoder = new StringDecoder("utf8"); + let position = 0; + let line = ""; + + while (position < logOffset) { + const bytesRead = fs.readSync( + fd, + buffer, + 0, + Math.min(buffer.length, logOffset - position), + position, + ); + if (bytesRead === 0) break; + position += bytesRead; + + const chunk = decoder.write(buffer.subarray(0, bytesRead)); + let start = 0; + for (let newline = chunk.indexOf("\n"); newline >= 0; newline = chunk.indexOf("\n", start)) { + line += chunk.slice(start, newline); + if (line) parser(line, sessionId); + line = ""; + start = newline + 1; + } + line += chunk.slice(start); + } + + // The persisted offset is the end of a complete line. Discard any + // unterminated fragment so an invalid offset cannot turn log bytes into + // assistant text or cause the live tailer to replay part of a line. + decoder.end(); + } catch { + // A missing or unreadable old log does not prevent the runtime from attaching. + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} // Pure so the flag spellings and effort clamping are testable without spawning agy. export function buildAntigravityArgs(options: StartOptions): string[] { @@ -86,8 +166,10 @@ export function parseAntigravityModelsList(stdout: string): ProviderModels[] { export class AntigravityDriver extends SessionDriver { readonly id = "antigravity" as const; + private readonly parser = createAntigravityParser(); + protected readonly hooks = createRuntimeHooks({ - parse: parseAntigravityLine, + parse: this.parser, nativeKeys: ["conversation_id"], harness: "Antigravity", plainTextFallback: true, @@ -97,6 +179,42 @@ export class AntigravityDriver extends SessionDriver { private detectedPath?: string; + override async start(options: StartOptions): Promise { + this.parser.reset(options.sessionId); + return super.start(options); + } + + override async attach(request: ReattachRequest): Promise { + this.parser.reset(request.sessionId); + const logOffset = alignAntigravityLogOffset( + sessionLogPaths(request.sessionId).stdoutPath, + request.logOffset ?? 0, + ); + this.replayConsumedOutput(request.sessionId, logOffset); + await super.attach({ ...request, logOffset }); + } + + override async stop(session: DriverSession): Promise { + try { + await super.stop(session); + } finally { + this.parser.reset(session.id); + } + } + + override async *events(session: DriverSession): AsyncIterable { + for await (const event of super.events(session)) { + if (event.type === "session.completed" || event.type === "session.failed") { + this.parser.reset(session.id); + } + yield event; + } + } + + private replayConsumedOutput(sessionId: string, logOffset?: number): void { + replayAntigravityOutput(sessionLogPaths(sessionId).stdoutPath, logOffset ?? 0, this.parser, sessionId); + } + protected override getCommand(): string { if (this.detectedPath) return this.detectedPath; const localBin = path.join(os.homedir(), ".local", "bin", "agy"); diff --git a/src/drivers/antigravity/parser.ts b/src/drivers/antigravity/parser.ts index 74faa2a..0f35d38 100644 --- a/src/drivers/antigravity/parser.ts +++ b/src/drivers/antigravity/parser.ts @@ -1,11 +1,56 @@ import type { AgentEvent } from "../../core/events.js"; import { classifyFailure } from "../../core/errors.js"; +// Keep fallback reconstruction bounded while retaining ordinary long answers. +const MAX_ACCUMULATED_RESPONSE_CHARS = 8 * 1024 * 1024; + +interface ResponseAccumulator { + chunks: string[]; + firstChunk: number; + length: number; + truncatedChars: number; + nextCompactionAt: number; +} + +export type AntigravityParser = ((line: string, sessionId: string) => AgentEvent[]) & { + reset: (sessionId: string) => void; +}; + export function parseAntigravityLine(line: string, sessionId: string): AgentEvent[] { + return parseAntigravityLineWithState(line, sessionId); +} + +// A result line can omit response text after streaming it through step_update +// lines. Keep this state per parser instance so separate runtimes cannot mix +// their transcripts while the stateless export remains useful in tests. +export function createAntigravityParser(): AntigravityParser { + const responseBySession = new Map(); + const parse: AntigravityParser = (line, sessionId) => + parseAntigravityLineWithState(line, sessionId, responseBySession); + parse.reset = (sessionId) => responseBySession.delete(sessionId); + return parse; +} + +function parseAntigravityLineWithState( + line: string, + sessionId: string, + responseBySession?: Map, +): AgentEvent[] { let obj: any; try { obj = JSON.parse(line); } catch { + if (responseBySession && line.trim()) { + const delta = `${line}\n`; + appendResponseText(responseBySession, sessionId, delta); + return [{ + type: "text.delta", + sessionId, + timestamp: new Date().toISOString(), + delta, + raw: line, + } as AgentEvent]; + } return []; } @@ -17,6 +62,7 @@ export function parseAntigravityLine(line: string, sessionId: string): AgentEven // Handle generic error object if (obj.error && !obj.event) { + responseBySession?.delete(sessionId); const errText = typeof obj.error === "string" ? obj.error : obj.error.message || JSON.stringify(obj.error); events.push({ type: "session.failed", @@ -31,6 +77,7 @@ export function parseAntigravityLine(line: string, sessionId: string): AgentEven // 1. "init" event -> session.started if (obj.event === "init") { + responseBySession?.delete(sessionId); const nativeSessionId = obj.conversation_id || obj.init?.conversation_id; events.push({ type: "session.started", @@ -49,6 +96,9 @@ export function parseAntigravityLine(line: string, sessionId: string): AgentEven // Streamed assistant text delta if (update.step_type === "agent_response" && typeof update.text_delta === "string" && update.text_delta) { + if (responseBySession) { + appendResponseText(responseBySession, sessionId, update.text_delta); + } events.push({ type: "text.delta", sessionId, @@ -128,6 +178,7 @@ export function parseAntigravityLine(line: string, sessionId: string): AgentEven const isError = result.status === "ERROR" || Boolean(result.error); if (isError) { + responseBySession?.delete(sessionId); const errText = result.error || result.response || "Antigravity execution failed"; events.push({ type: "session.failed", @@ -156,18 +207,43 @@ export function parseAntigravityLine(line: string, sessionId: string): AgentEven } as AgentEvent); } - if (result.response && typeof result.response === "string" && result.response.trim()) { + const response = typeof result.response === "string" ? result.response : ""; + const accumulator = responseBySession?.get(sessionId); + const accumulatedContent = !response.trim() && accumulator ? accumulatedResponseContent(accumulator) : ""; + const content = response.trim() + ? response + : accumulatedContent.trim() && accumulator + ? accumulatedResponseText(accumulator, accumulatedContent) + : ""; + + if (content.trim()) { events.push({ type: "message", sessionId, timestamp: ts, role: "assistant", - content: result.response, + content, + nativeSessionId, + raw, + } as AgentEvent); + } else { + const error = accumulator + ? "Antigravity returned an empty or whitespace-only response with no usable text deltas" + : "Antigravity returned an empty or whitespace-only response with no text deltas"; + responseBySession?.delete(sessionId); + events.push({ + type: "session.failed", + sessionId, + timestamp: ts, + error, + failure: classifyFailure(error), nativeSessionId, raw, } as AgentEvent); + return events; } + responseBySession?.delete(sessionId); events.push({ type: "session.completed", sessionId, @@ -183,3 +259,62 @@ export function parseAntigravityLine(line: string, sessionId: string): AgentEven return []; } + +function appendResponseText( + responseBySession: Map, + sessionId: string, + delta: string, +): void { + const accumulator = responseBySession.get(sessionId) ?? { + chunks: [], + firstChunk: 0, + length: 0, + truncatedChars: 0, + nextCompactionAt: 1024, + }; + accumulator.chunks.push(delta); + accumulator.length += delta.length; + + while (accumulator.length > MAX_ACCUMULATED_RESPONSE_CHARS) { + const first = accumulator.chunks[accumulator.firstChunk]!; + let remove = Math.min(first.length, accumulator.length - MAX_ACCUMULATED_RESPONSE_CHARS); + if (remove === first.length) { + accumulator.firstChunk += 1; + } else { + accumulator.chunks[accumulator.firstChunk] = first.slice(remove); + } + accumulator.length -= remove; + accumulator.truncatedChars += remove; + } + + const first = accumulator.chunks[accumulator.firstChunk]; + if (first && first.charCodeAt(0) >= 0xdc00 && first.charCodeAt(0) <= 0xdfff) { + accumulator.chunks[accumulator.firstChunk] = first.slice(1); + accumulator.length -= 1; + accumulator.truncatedChars += 1; + } + + // Avoid retaining references to every discarded delta. Compaction is + // amortized so append remains linear even after the cap is reached. + if (accumulator.firstChunk > 1024 && accumulator.firstChunk * 2 >= accumulator.chunks.length) { + accumulator.chunks = accumulator.chunks.slice(accumulator.firstChunk); + accumulator.firstChunk = 0; + } + const liveChunks = accumulator.chunks.length - accumulator.firstChunk; + if (liveChunks >= accumulator.nextCompactionAt) { + accumulator.chunks = [accumulator.chunks.slice(accumulator.firstChunk).join("")]; + accumulator.firstChunk = 0; + accumulator.nextCompactionAt = Math.max(accumulator.nextCompactionAt * 2, liveChunks * 2); + } + responseBySession.set(sessionId, accumulator); +} + +function accumulatedResponseText(accumulator: ResponseAccumulator, text: string): string { + return accumulator.truncatedChars > 0 + ? `[truncated ${accumulator.truncatedChars} characters]\n${text}` + : text; +} + +function accumulatedResponseContent(accumulator: ResponseAccumulator): string { + return accumulator.chunks.slice(accumulator.firstChunk).join(""); +} diff --git a/tests/antigravity-driver.test.ts b/tests/antigravity-driver.test.ts index 4642afc..b39a426 100644 --- a/tests/antigravity-driver.test.ts +++ b/tests/antigravity-driver.test.ts @@ -1,13 +1,30 @@ import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { buildAntigravityArgs, parseAntigravityModelsList, AntigravityDriver, + alignAntigravityLogOffset, + replayAntigravityOutput, } from "../src/drivers/antigravity/driver.js"; -import { parseAntigravityLine } from "../src/drivers/antigravity/parser.js"; +import { createAntigravityParser, parseAntigravityLine } from "../src/drivers/antigravity/parser.js"; +import type { StartOptions } from "../src/core/driver.js"; import type { AgentEvent } from "../src/core/events.js"; const S = "test-session"; +const resultLine = (response = "", extra: Record = {}): string => + JSON.stringify({ event: "result", result: { status: "SUCCESS", response, ...extra } }); +const deltaLine = (textDelta: string): string => + JSON.stringify({ + event: "step_update", + step_update: { step_type: "agent_response", text_delta: textDelta }, + }); +const contentAfterEmptyResult = (feed: (line: string) => AgentEvent[]): unknown => { + const message = feed(resultLine()).find((event) => event.type === "message") as any; + return message?.content; +}; const base = { sessionId: S, prompt: "do something", cwd: "/workspace" }; describe("buildAntigravityArgs", () => { @@ -185,6 +202,237 @@ describe("parseAntigravityLine", () => { expect(usageEv.usage.cachedTokens).toBe(800); }); + it.each(["", " \n\t"])("fails a successful result with an empty response (%p)", (response) => { + const events = parse(resultLine(response, { conversation_id: "conv-uuid-1" })); + + expect(events.map((event) => event.type)).toEqual(["session.failed"]); + expect((events[0] as any).error).toMatch(/empty|whitespace/i); + expect((events[0] as any).error).toMatch(/text deltas/i); + }); + + it("uses accumulated text.delta chunks when the successful result response is empty", () => { + const streamParser = createAntigravityParser(); + streamParser( + deltaLine("Hello, "), + S, + ); + streamParser( + deltaLine("World!"), + S, + ); + + const events = streamParser( + resultLine("", { conversation_id: "conv-uuid-1" }), + S, + ); + + expect(events.map((event) => event.type)).toEqual(["message", "session.completed"]); + expect((events[0] as any).content).toBe("Hello, World!"); + }); + + it("uses accumulated text.delta chunks when the result response is whitespace", () => { + const streamParser = createAntigravityParser(); + streamParser( + deltaLine("Hello, World!"), + S, + ); + + const events = streamParser( + resultLine(" \n\t"), + S, + ); + + expect(events.map((event) => event.type)).toEqual(["message", "session.completed"]); + expect((events[0] as any).content).toBe("Hello, World!"); + }); + + it("prefers a non-empty result response over accumulated deltas", () => { + const streamParser = createAntigravityParser(); + streamParser( + deltaLine("stale"), + S, + ); + + const events = streamParser( + resultLine("final"), + S, + ); + + expect((events.find((event) => event.type === "message") as any).content).toBe("final"); + + const afterResult = streamParser( + resultLine(), + S, + ); + expect(afterResult.map((event) => event.type)).toEqual(["session.failed"]); + }); + + it("fails when only whitespace text deltas were seen", () => { + const streamParser = createAntigravityParser(); + streamParser( + deltaLine(" \n\t"), + S, + ); + + const events = streamParser( + resultLine(""), + S, + ); + + expect(events.map((event) => event.type)).toEqual(["session.failed"]); + expect((events[0] as any).error).toContain("no usable text deltas"); + }); + + it("clears accumulated text after an empty-result failure", () => { + const streamParser = createAntigravityParser(); + streamParser( + deltaLine(" \n"), + S, + ); + const firstResult = streamParser( + resultLine(), + S, + ); + const secondResult = streamParser( + resultLine(), + S, + ); + + expect(firstResult.map((event) => event.type)).toEqual(["session.failed"]); + expect(secondResult.map((event) => event.type)).toEqual(["session.failed"]); + expect((secondResult[0] as any).error).toContain("no text deltas"); + }); + + it("clears accumulated text when a new init event starts", () => { + const streamParser = createAntigravityParser(); + streamParser( + deltaLine("stale"), + S, + ); + streamParser(JSON.stringify({ event: "init", conversation_id: "new-conversation" }), S); + + const events = streamParser( + resultLine(), + S, + ); + expect(events.map((event) => event.type)).toEqual(["session.failed"]); + }); + + it("clears accumulated text after an error result", () => { + const streamParser = createAntigravityParser(); + streamParser( + deltaLine("stale"), + S, + ); + streamParser( + JSON.stringify({ event: "result", result: { status: "ERROR", error: "failed" } }), + S, + ); + + const events = streamParser( + resultLine(), + S, + ); + expect(events.map((event) => event.type)).toEqual(["session.failed"]); + }); + + it("clears accumulated text after a generic error line", () => { + const streamParser = createAntigravityParser(); + streamParser( + deltaLine("stale"), + S, + ); + streamParser(JSON.stringify({ error: "failed" }), S); + + const events = streamParser( + resultLine(), + S, + ); + expect(events.map((event) => event.type)).toEqual(["session.failed"]); + }); + + it("marks text truncated when accumulated output exceeds the safety limit", () => { + const streamParser = createAntigravityParser(); + const oversized = "x".repeat(8 * 1024 * 1024 + 10); + streamParser( + deltaLine(oversized), + S, + ); + + const events = streamParser( + resultLine(), + S, + ); + const content = (events.find((event) => event.type === "message") as any).content as string; + + expect(content).toMatch(/^\[truncated 10 characters\]\n/); + expect(content.endsWith("x".repeat(8 * 1024 * 1024))).toBe(true); + }); + + it("does not retain a lone surrogate at the accumulated output boundary", () => { + const streamParser = createAntigravityParser(); + const cap = 8 * 1024 * 1024; + streamParser( + deltaLine("x".repeat(9) + "\ud83d"), + S, + ); + streamParser( + deltaLine("\ude00" + "y".repeat(cap - 1)), + S, + ); + + const events = streamParser( + resultLine(), + S, + ); + const content = (events.find((event) => event.type === "message") as any).content as string; + + expect(content).toBe(`[truncated 11 characters]\n${"y".repeat(cap - 1)}`); + expect(content.charCodeAt("[truncated 11 characters]\n".length)).not.toBe(0xde00); + }); + + it("keeps accumulated responses isolated by session", () => { + const streamParser = createAntigravityParser(); + const delta = (sessionId: string, text: string) => streamParser(deltaLine(text), sessionId); + const result = (sessionId: string) => streamParser(resultLine(), sessionId); + + delta("session-a", "AAA"); + delta("session-b", "BBB"); + + expect((result("session-a").find((event) => event.type === "message") as any).content).toBe("AAA"); + expect((result("session-b").find((event) => event.type === "message") as any).content).toBe("BBB"); + }); + + it("resets accumulated text for a new turn", () => { + const streamParser = createAntigravityParser(); + streamParser( + deltaLine("stale"), + S, + ); + streamParser.reset(S); + + const events = streamParser( + resultLine(), + S, + ); + + expect(events.map((event) => event.type)).toEqual(["session.failed"]); + }); + + it("accumulates plain-text output for an empty result response", () => { + const streamParser = createAntigravityParser(); + expect(streamParser("Here is the plain-text answer.", S)[0]?.type).toBe("text.delta"); + + const events = streamParser( + resultLine(), + S, + ); + + expect((events.find((event) => event.type === "message") as any).content).toBe( + "Here is the plain-text answer.\n", + ); + }); + it("maps SUCCESS result event to message, usage, and session.completed", () => { const line = JSON.stringify({ event: "result", @@ -283,4 +531,213 @@ describe("AntigravityDriver", () => { expect(caps.modelSelection).toBe(true); expect(caps.interrupt).toBe(true); }); + + it("replays only the consumed complete output before reattach", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "antigravity-replay-")); + const stdoutPath = path.join(tempDir, "session.ndjson"); + const firstLine = JSON.stringify({ + event: "step_update", + step_update: { step_type: "agent_response", text_delta: "Hello, " }, + }); + const secondLine = JSON.stringify({ + event: "step_update", + step_update: { step_type: "agent_response", text_delta: "World!" }, + }); + const thirdLine = JSON.stringify({ + event: "step_update", + step_update: { step_type: "agent_response", text_delta: " Again!" }, + }); + const tailLine = JSON.stringify({ + event: "step_update", + step_update: { step_type: "agent_response", text_delta: " Do not replay." }, + }); + const output = `${firstLine}\n${secondLine}\n${thirdLine}\n${tailLine}\n`; + fs.writeFileSync(stdoutPath, output); + + try { + const streamParser = createAntigravityParser(); + replayAntigravityOutput( + stdoutPath, + Buffer.byteLength(`${firstLine}\n${secondLine}\n${thirdLine}\n`), + streamParser, + S, + ); + const feed = (line: string) => streamParser(line, S); + expect(contentAfterEmptyResult(feed)).toBe("Hello, World! Again!"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("discards an unterminated replay fragment at a mid-line offset", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "antigravity-replay-fragment-")); + const stdoutPath = path.join(tempDir, "session.ndjson"); + const firstLine = JSON.stringify({ + event: "step_update", + step_update: { step_type: "agent_response", text_delta: "Hello, " }, + }); + const partialPlainText = "do not replay this partial line"; + fs.writeFileSync(stdoutPath, `${firstLine}\n${partialPlainText}\n`); + + try { + const streamParser = createAntigravityParser(); + const firstLineEnd = Buffer.byteLength(`${firstLine}\n`); + replayAntigravityOutput(stdoutPath, firstLineEnd + 5, streamParser, S); + const feed = (line: string) => streamParser(line, S); + expect(contentAfterEmptyResult(feed)).toBe("Hello, "); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("aligns reattach offsets to the start of a complete line", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "antigravity-offset-")); + const stdoutPath = path.join(tempDir, "session.ndjson"); + const firstLine = JSON.stringify({ event: "step_update", step_update: { text_delta: "first" } }); + const secondLine = JSON.stringify({ event: "step_update", step_update: { text_delta: "second" } }); + fs.writeFileSync(stdoutPath, `${firstLine}\n${secondLine}\n`); + + try { + const lineStart = Buffer.byteLength(`${firstLine}\n`); + expect(alignAntigravityLogOffset(stdoutPath, lineStart + 5)).toBe(lineStart); + expect(alignAntigravityLogOffset(stdoutPath, lineStart)).toBe(lineStart); + expect(alignAntigravityLogOffset(stdoutPath, fs.statSync(stdoutPath).size + 1)).toBe(0); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("resets parser state before attaching a session", async () => { + const driver = new AntigravityDriver(); + const parser = (driver as any).parser as ReturnType; + parser( + deltaLine("stale"), + S, + ); + + await driver.attach({ sessionId: S }); + + const events = parser( + resultLine(), + S, + ); + expect(events.map((event) => event.type)).toEqual(["session.failed"]); + }); + + it("resets parser state when stopping a session", async () => { + const driver = new AntigravityDriver(); + const parser = (driver as any).parser as ReturnType; + parser( + deltaLine("stale"), + S, + ); + (driver as any).handles.set(S, { stop: async () => {} }); + + await driver.stop({ id: S } as any); + + const events = parser( + resultLine(), + S, + ); + expect(events.map((event) => event.type)).toEqual(["session.failed"]); + }); + + it("resets parser state when an event stream reaches a terminal event", async () => { + const driver = new AntigravityDriver(); + const parser = (driver as any).parser as ReturnType; + parser( + deltaLine("stale"), + S, + ); + (driver as any).handles.set(S, { + events: async function* () { + yield { type: "session.failed", sessionId: S, timestamp: new Date().toISOString(), error: "done" }; + }, + }); + + const events: AgentEvent[] = []; + for await (const event of driver.events({ id: S } as any)) events.push(event); + expect(events).toHaveLength(1); + + const afterTerminal = parser( + resultLine(), + S, + ); + expect(afterTerminal.map((event) => event.type)).toEqual(["session.failed"]); + }); + + it("resets parser state before starting a new turn", async () => { + class NoopAntigravityDriver extends AntigravityDriver { + protected override getCommand(): string { + return process.execPath; + } + + protected override buildArgs(_options: StartOptions): string[] { + return ["-e", ""]; + } + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "antigravity-start-")); + const previousRunAgentDir = process.env.RUN_AGENT_DIR; + const previousNoScope = process.env.CODEDECK_NO_SCOPE; + process.env.RUN_AGENT_DIR = tempDir; + process.env.CODEDECK_NO_SCOPE = "1"; + let session: Awaited> | undefined; + + try { + const driver = new NoopAntigravityDriver(); + const parser = (driver as any).parser as ReturnType; + parser( + deltaLine("stale"), + S, + ); + + session = await driver.start({ ...base, cwd: tempDir }); + const afterStart = parser( + resultLine(), + S, + ); + expect(afterStart.map((event) => event.type)).toEqual(["session.failed"]); + + const emitted: AgentEvent[] = []; + for await (const event of driver.events(session)) emitted.push(event); + expect(emitted.at(-1)?.type).toBe("session.completed"); + await driver.stop(session); + } finally { + if (previousRunAgentDir === undefined) delete process.env.RUN_AGENT_DIR; + else process.env.RUN_AGENT_DIR = previousRunAgentDir; + if (previousNoScope === undefined) delete process.env.CODEDECK_NO_SCOPE; + else process.env.CODEDECK_NO_SCOPE = previousNoScope; + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("uses the session stdout path when attaching", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "antigravity-attach-")); + const previousRunAgentDir = process.env.RUN_AGENT_DIR; + process.env.RUN_AGENT_DIR = tempDir; + const stdoutPath = path.join(tempDir, "logs", `${S}.ndjson`); + const firstLine = JSON.stringify({ + event: "step_update", + step_update: { step_type: "agent_response", text_delta: "from stdout" }, + }); + fs.mkdirSync(path.dirname(stdoutPath), { recursive: true }); + fs.writeFileSync(stdoutPath, `${firstLine}\n`); + + try { + const driver = new AntigravityDriver(); + await driver.attach({ sessionId: S, logOffset: Buffer.byteLength(`${firstLine}\n`) }); + const parser = (driver as any).parser as ReturnType; + const events = parser( + resultLine(), + S, + ); + + expect((events.find((event) => event.type === "message") as any).content).toBe("from stdout"); + } finally { + if (previousRunAgentDir === undefined) delete process.env.RUN_AGENT_DIR; + else process.env.RUN_AGENT_DIR = previousRunAgentDir; + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/profiles.test.ts b/tests/profiles.test.ts index 56a3ab0..0fd3ae2 100644 --- a/tests/profiles.test.ts +++ b/tests/profiles.test.ts @@ -140,6 +140,30 @@ describe("profile actions", () => { expect(result.config).not.toHaveProperty("activeProfile"); }); + it("saves a new profile from the base without active-profile contamination", () => { + const config: RunAgentConfig = { + defaultAgent: "claude", + agents: { general: { harness: "claude", model: "base-model" } }, + activeProfile: "a", + profiles: { a: { agents: { general: { harness: "codex", model: "from-a" } } } }, + }; + const result = applyProfileAction(config, "save", "b"); + expect(result.save).toBe(true); + expect(result.config.profiles?.b?.agents?.general?.model).toBe("base-model"); + expect(result.config.profiles?.a?.agents?.general?.model).toBe("from-a"); + }); + + it("saving the active profile snapshots its own effective setup", () => { + const config: RunAgentConfig = { + defaultAgent: "claude", + agents: { general: { harness: "claude", model: "base-model" } }, + activeProfile: "a", + profiles: { a: { agents: { general: { harness: "codex", model: "from-a" } } } }, + }; + const result = applyProfileAction(config, "save", "a"); + expect(result.config.profiles?.a?.agents?.general?.model).toBe("from-a"); + }); + it("uses, lists and shows profiles", () => { const withProfile = applyProfileAction(saved, "save", "max").config; const used = applyProfileAction(withProfile, "use", "max"); @@ -261,7 +285,7 @@ describe("setup --profile batch", () => { }); }); - it("creates a missing profile from the base setup", async () => { + it("creates a missing profile empty apart from the bind, without root leakage", async () => { const { store, deps } = batchWorld(); const result = await runSetupBatch( setupOptions(["--non-interactive", "--bind", "reviewer=codex:gpt-5", "--profile=max"]), @@ -270,7 +294,45 @@ describe("setup --profile batch", () => { expect(result.code).toBe(0); const written = store.save.mock.calls[0][0] as RunAgentConfig; - expect(written.profiles?.max?.agents?.general).toEqual({ harness: "claude", model: "base" }); + expect(written.profiles?.max?.agents?.reviewer).toEqual({ harness: "codex", model: "gpt-5" }); + expect(written.profiles?.max?.agents?.general).toBeUndefined(); + expect(written.agents?.general).toEqual({ harness: "claude", model: "base" }); + }); + + it("setup without --profile edits the base and does not clobber the active snapshot", async () => { + const file = getPaths().configFile; + const activeSnapshot = { agents: { general: { harness: "codex", model: "from-a" } } } as const; + const effective = { + ...DEFAULT_CONFIG, + agents: { general: { harness: "claude", model: "base" } }, + activeProfile: "a", + profiles: { a: activeSnapshot }, + }; + const read = vi.fn( + (): SetupConfigRead => ({ + status: "ok", + source: "canonical", + path: file, + config: effective as RunAgentConfig, + raw: serializeConfig(effective as RunAgentConfig), + message: null, + }), + ); + const save = vi.fn(); + const deps: SetupBatchDependencies = { + configStore: { read, save }, + registry: fakeRegistry("codex"), + discoverModels: async () => [catalog("codex", ["gpt-5"])], + saveCache: () => true, + }; + const result = await runSetupBatch(setupOptions(["--bind", "reviewer=codex:gpt-5"]), deps); + + expect(result.code).toBe(0); + const written = save.mock.calls[0][0] as RunAgentConfig; + expect(written.activeProfile).toBe("a"); + expect(written.profiles?.a).toEqual(activeSnapshot); + expect(written.agents?.reviewer).toEqual({ harness: "codex", model: "gpt-5" }); + expect(written.agents?.general).toEqual({ harness: "claude", model: "base" }); }); it("rejects a bad profile name at parse time", () => { diff --git a/tests/setup-cli-contract.test.ts b/tests/setup-cli-contract.test.ts index 27a5a23..2b45033 100644 --- a/tests/setup-cli-contract.test.ts +++ b/tests/setup-cli-contract.test.ts @@ -238,6 +238,37 @@ describe("setup batch execution", () => { expect(store.save).toHaveBeenCalledOnce(); }); + it("writes a new profile with only the bind and leaves the file without root leakage", async () => { + const now = Date.now(); + const file = getPaths().configFile; + const before: RunAgentConfig = { + defaultAgent: "claude", + agents: { general: { harness: "claude", model: "base-model" } }, + defaultSandbox: "danger-full-access", + }; + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + fs.writeFileSync(file, serializeConfig({ ...DEFAULT_CONFIG, ...before }), "utf8"); + + const result = await executeSetupAction( + ["--non-interactive", "--bind", "reviewer=codex:gpt-5", "--profile=newp"], + { + isTTY: false, + registry: fakeRegistry("codex"), + discoverModels: async () => [catalog("codex", [{ id: "gpt-5" }], new Date(now).toISOString())], + saveCache: () => true, + now: () => now, + timeoutMs: 20, + }, + ); + + expect(result.code).toBe(0); + const saved = JSON.parse(fs.readFileSync(file, "utf8")) as RunAgentConfig; + expect(saved.profiles?.newp?.agents?.reviewer).toEqual({ harness: "codex", model: "gpt-5" }); + expect(saved.profiles?.newp?.agents?.general).toBeUndefined(); + expect(saved.profiles?.newp?.defaultSandbox).toBe("danger-full-access"); + expect(saved.agents?.general).toEqual({ harness: "claude", model: "base-model" }); + }); + it("serializes parser failures with the not-run catalog state", async () => { const stdout = Object.assign(new MemoryWritable(), { isTTY: false }); const stderr = new MemoryWritable(); diff --git a/tests/setup-wizard.test.ts b/tests/setup-wizard.test.ts index 086bc31..4c97217 100644 --- a/tests/setup-wizard.test.ts +++ b/tests/setup-wizard.test.ts @@ -160,6 +160,67 @@ describe("runModelSetupWizard", () => { expect(save).not.toHaveBeenCalled(); }); + it("starts a new --profile from the base with an empty agent map", async () => { + const discoverModels = vi.fn(async () => discoveredHarnesses()); + const save = vi.fn(); + const config: RunAgentConfig = { + defaultAgent: "omp", + agents: { general: { harness: "claude", model: "base-model" } }, + defaultSandbox: "danger-full-access", + autocompact: { enabled: true, cap: 300_000 }, + activeProfile: "a", + profiles: { a: { agents: { general: { harness: "codex", model: "from-a" } } } }, + }; + + const fresh = await runModelSetupWizard({ + config, + profile: "brand-new", + isTTY: false, + discoverModels, + save, + }); + // No role leakage, but the inherited toggles stay visible: blanking them + // would silently downgrade the base values on confirm. + expect(fresh.agents).toEqual({}); + expect(fresh.defaultSandbox).toBe("danger-full-access"); + expect(fresh.autocompact).toEqual({ enabled: true, cap: 300_000 }); + expect(fresh.defaultAgent).toBe("omp"); + expect(buildSandboxScreen(fresh).items[0]).toMatchObject({ + id: "danger-full-access", + note: "atual", + }); + expect(buildAutocompactScreen(fresh).items[0]).toMatchObject({ id: "on", note: "atual" }); + expect(discoverModels).not.toHaveBeenCalled(); + + const existing = await runModelSetupWizard({ + config, + profile: "a", + isTTY: false, + discoverModels, + save, + }); + expect(existing.agents?.general).toEqual({ harness: "codex", model: "from-a" }); + }); + + it("keeps the inherited sandbox and autocompact values when creating a profile", async () => { + const { input, output } = io(); + const save = vi.fn(); + const config: RunAgentConfig = { + ...base().config, + defaultSandbox: "danger-full-access", + autocompact: { enabled: true, cap: 300_000 }, + }; + drive(input, output, ["\x07", "\x07", "\x07", "\x07", "\r", "\r", "\r"]); + + await runModelSetupWizard({ ...base(), config, profile: "brand-new", input, output, save }); + + expect(save).toHaveBeenCalledOnce(); + const written = save.mock.calls[0][0] as RunAgentConfig; + expect(written.profiles?.["brand-new"]?.agents).toEqual({}); + expect(written.profiles?.["brand-new"]?.defaultSandbox).toBe("danger-full-access"); + expect(written.profiles?.["brand-new"]?.autocompact).toEqual({ enabled: true, cap: 300_000 }); + }); + it.each([ ["absent", undefined, ["OFF", "ON"]], ["disabled", { enabled: false }, ["OFF", "ON"]],