diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 99e169f91..1cd7f8712 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2428,10 +2428,11 @@ export class AgentSession { // No compaction candidates, fallback to re-prime return true; } + const advisorSessionId = this.#advisorSessionId(advisor.slug); const preparation = prepareCompaction( pathEntries, compactionSettings, - candidates.filter(model => this.#modelRegistry.hasConfiguredAuth(model)), + await this.#runnableCompactionCandidates(candidates, advisorSessionId), ); if (!preparation) { // Cannot prepare compaction, fallback to re-prime @@ -2449,7 +2450,6 @@ export class AgentSession { let compactResult: CompactionResult | undefined; let lastError: unknown; - const advisorSessionId = this.#advisorSessionId(advisor.slug); // Instrument the advisor's overflow-compaction one-shot like the primary // compaction path so the advisor model's maintenance call also emits spans. const telemetry = resolveTelemetry(agent.telemetry, advisorSessionId); @@ -8778,7 +8778,7 @@ export class AgentSession { const preparation = prepareCompaction( pathEntries, effectiveSettings, - compactionCandidates.filter(model => this.#modelRegistry.hasConfiguredAuth(model)), + await this.#runnableCompactionCandidates(compactionCandidates, this.sessionId), ); if (!preparation) { // Check why we can't compact @@ -10553,6 +10553,17 @@ export class AgentSession { return this.#resolveCompactionModelCandidates(this.model, availableModels, filter); } + /** + * Compaction candidates that can actually run — those with a resolvable API + * key, matching the per-candidate getApiKey gate the execution loop applies. + * Re-expansion reusability (prepareCompaction) must judge remote-preserve + * reuse against these, not against candidates the loop would skip at runtime. + */ + async #runnableCompactionCandidates(candidates: readonly Model[], sessionId: string | undefined): Promise { + const keys = await Promise.all(candidates.map(model => this.#modelRegistry.getApiKey(model, sessionId))); + return candidates.filter((_, index) => keys[index] !== undefined); + } + #resolveCompactionModelCandidates( preferredModel: Model | null | undefined, availableModels: Model[], @@ -11064,8 +11075,9 @@ export class AgentSession { const pathEntries = this.sessionManager.getBranch(); - const autoCompactionCandidates = this.#getCompactionModelCandidates(availableModels).filter(model => - this.#modelRegistry.hasConfiguredAuth(model), + const autoCompactionCandidates = await this.#runnableCompactionCandidates( + this.#getCompactionModelCandidates(availableModels), + this.sessionId, ); const preparation = prepareCompaction(pathEntries, compactionSettings, autoCompactionCandidates); if (!preparation) { diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index d78497731..049e39d32 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -73,6 +73,7 @@ import { type TaskToolDetails, type YieldItem, } from "./types"; +import { arrayValuedLabels, assembleYieldResult } from "./yield-assembly"; export type { YieldItem } from "./types"; @@ -520,200 +521,6 @@ function resolveFallbackCompletion(rawOutput: string, outputSchema: unknown): { return { data: candidate }; } -interface AssembledYieldResult { - data: unknown; - schemaOverridden: boolean; - rawText: boolean; - missingData: boolean; -} - -function isIncrementalYieldType(type: YieldItem["type"]): type is string[] { - return Array.isArray(type) && type.length > 0; -} - -function getYieldLabels(type: YieldItem["type"]): string[] { - if (typeof type === "string") { - const label = type.trim(); - return label ? [label] : []; - } - if (!Array.isArray(type)) return []; - const labels: string[] = []; - for (const value of type) { - if (typeof value !== "string") continue; - const label = value.trim(); - if (label) labels.push(label); - } - return labels; -} - -function resolveYieldPayload( - item: YieldItem, - lastAssistantText: string | undefined, - labels: string[], -): { value: unknown; fromLastAssistantText: boolean; missingData: boolean } { - const hasData = item.data !== undefined; - const shouldUseLastTurn = item.useLastTurn === true || (labels.length > 0 && !hasData); - if (shouldUseLastTurn && lastAssistantText !== undefined) { - return { - value: lastAssistantText, - fromLastAssistantText: true, - missingData: lastAssistantText.length === 0, - }; - } - return { - value: item.data, - fromLastAssistantText: false, - missingData: item.data === undefined || item.data === null, - }; -} - -function appendYieldSection( - sections: Record, - sectionCounts: Map, - label: string, - value: unknown, - forceArray: boolean, -): void { - const count = sectionCounts.get(label) ?? 0; - const existing = sections[label]; - if (count === 0) { - sections[label] = forceArray ? [value] : value; - } else if (Array.isArray(existing)) { - existing.push(value); - } else { - sections[label] = [existing, value]; - } - sectionCounts.set(label, count + 1); -} - -/** True when `value` is a JSON-schema node whose instances are arrays. */ -function isArrayTypedSchema(value: unknown): boolean { - if (value === null || typeof value !== "object") return false; - const record = value as Record; - if (record.type === "array") return true; - if (Array.isArray(record.type) && record.type.includes("array")) return true; - for (const key of ["anyOf", "oneOf", "allOf"] as const) { - const variants = record[key]; - if (Array.isArray(variants) && variants.some(isArrayTypedSchema)) return true; - } - return false; -} - -/** - * Top-level output-schema property names declared as arrays (JTD `elements` → - * JSON `type: "array"`). An incremental yield section for such a label - * accumulates into a list even when the agent emits exactly one — otherwise a - * single `type: ["findings"]` yield would assemble as a bare object and fail - * array-typed schema validation. - */ -function arrayValuedLabels(outputSchema: unknown): ReadonlySet { - const labels = new Set(); - // Use the JTD-converted JSON Schema (matches what validation runs against): - // JTD `optionalProperties.findings.elements` becomes `properties.findings` - // with `type: "array"`, which raw `normalizeSchema` would not expose. - const { jsonSchema } = buildOutputValidator(outputSchema); - if (jsonSchema === undefined) return labels; - const properties = jsonSchema.properties; - if (properties === null || typeof properties !== "object") return labels; - const propRecord = properties as Record; - for (const key in propRecord) { - if (isArrayTypedSchema(propRecord[key])) labels.add(key); - } - return labels; -} - -/** - * Assemble typed yield calls into the final payload consumed by schema validation. - * - * A non-empty array `type` contributes an incremental section and never decides - * termination by itself. A string `type` with omitted `data` makes the last - * assistant turn the raw terminal result. Other string-typed yields contribute - * the terminal labelled section. Untyped terminal yields keep the historical - * "last yield wins" behavior unless no terminal yield exists, in which case - * accumulated typed sections finalize on idle. - */ -export function assembleYieldResult( - yieldItems: YieldItem[], - lastAssistantText?: string, - arrayLabels?: ReadonlySet, -): AssembledYieldResult | undefined { - if (yieldItems.length === 0) return undefined; - let terminalItem: YieldItem | undefined; - for (let index = yieldItems.length - 1; index >= 0; index--) { - const item = yieldItems[index]; - if (!item) continue; - if (!isIncrementalYieldType(item.type)) { - terminalItem = item; - break; - } - } - let hasTypedSections = false; - for (const item of yieldItems) { - if (getYieldLabels(item.type).length > 0) { - hasTypedSections = true; - break; - } - } - if (terminalItem && typeof terminalItem.type === "string" && terminalItem.data === undefined) { - const resolved = resolveYieldPayload(terminalItem, lastAssistantText, getYieldLabels(terminalItem.type)); - return { - data: resolved.value, - schemaOverridden: terminalItem.schemaOverridden === true, - rawText: resolved.fromLastAssistantText && typeof resolved.value === "string", - missingData: resolved.missingData, - }; - } - if (!hasTypedSections && terminalItem) { - const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []); - return { - data: resolved.value, - schemaOverridden: terminalItem.schemaOverridden === true, - rawText: resolved.fromLastAssistantText && typeof resolved.value === "string", - missingData: resolved.missingData, - }; - } - - const sections: Record = {}; - const sectionCounts = new Map(); - let schemaOverridden = false; - let missingData = false; - let hasSections = false; - - for (const item of yieldItems) { - if (item.status === "aborted") continue; - schemaOverridden ||= item.schemaOverridden === true; - const labels = getYieldLabels(item.type); - if (labels.length === 0) continue; - const resolved = resolveYieldPayload(item, lastAssistantText, labels); - missingData ||= resolved.missingData; - const incremental = isIncrementalYieldType(item.type); - for (const label of labels) { - appendYieldSection( - sections, - sectionCounts, - label, - resolved.value, - incremental && (arrayLabels?.has(label) ?? false), - ); - hasSections = true; - } - if (!isIncrementalYieldType(item.type)) break; - } - - if (hasSections) { - return { data: sections, schemaOverridden, rawText: false, missingData }; - } - - if (!terminalItem) return undefined; - const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []); - return { - data: resolved.value, - schemaOverridden: terminalItem.schemaOverridden === true, - rawText: resolved.fromLastAssistantText && typeof resolved.value === "string", - missingData: resolved.missingData, - }; -} - interface FinalizeSubprocessOutputArgs { rawOutput: string; exitCode: number; diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index a5182f1c4..9742a8da9 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -32,10 +32,10 @@ import { type SubmitReviewDetails, } from "../tools/review"; import { framedBlock, renderStatusLine } from "../tui"; -import { assembleYieldResult } from "./executor"; import { repairDoubleEncodedJsonString } from "./repair-args"; import { subprocessToolRegistry } from "./subprocess-tool-registry"; import type { AgentProgress, SingleResult, TaskItem, TaskParams, TaskToolDetails, YieldItem } from "./types"; +import { assembleYieldResult } from "./yield-assembly"; /** Render context threaded in from `ToolExecutionComponent.#buildRenderContext`. */ interface TaskRenderContext { diff --git a/packages/coding-agent/src/task/yield-assembly.ts b/packages/coding-agent/src/task/yield-assembly.ts new file mode 100644 index 000000000..810f1c012 --- /dev/null +++ b/packages/coding-agent/src/task/yield-assembly.ts @@ -0,0 +1,207 @@ +/** + * Pure assembly of subagent `yield` calls into the final payload consumed by + * output-schema validation. + * + * Lives apart from the subagent runtime in `executor.ts` so the rendering path + * (`render.ts` → `extractIncrementalReviewResult`) can assemble incremental + * yields without importing that runtime's dependency graph (`sdk`, + * `session-manager`, the TUI tool renderers). It has no side effects and + * depends only on the yield type and the output-schema validator. + */ +import { buildOutputValidator } from "../tools/output-schema-validator"; +import type { YieldItem } from "./types"; + +/** Outcome of folding a run's yield calls into one payload, with provenance flags. */ +interface AssembledYieldResult { + data: unknown; + schemaOverridden: boolean; + rawText: boolean; + missingData: boolean; +} + +function isIncrementalYieldType(type: YieldItem["type"]): type is string[] { + return Array.isArray(type) && type.length > 0; +} + +function getYieldLabels(type: YieldItem["type"]): string[] { + if (typeof type === "string") { + const label = type.trim(); + return label ? [label] : []; + } + if (!Array.isArray(type)) return []; + const labels: string[] = []; + for (const value of type) { + if (typeof value !== "string") continue; + const label = value.trim(); + if (label) labels.push(label); + } + return labels; +} + +function resolveYieldPayload( + item: YieldItem, + lastAssistantText: string | undefined, + labels: string[], +): { value: unknown; fromLastAssistantText: boolean; missingData: boolean } { + const hasData = item.data !== undefined; + const shouldUseLastTurn = item.useLastTurn === true || (labels.length > 0 && !hasData); + if (shouldUseLastTurn && lastAssistantText !== undefined) { + return { + value: lastAssistantText, + fromLastAssistantText: true, + missingData: lastAssistantText.length === 0, + }; + } + return { + value: item.data, + fromLastAssistantText: false, + missingData: item.data === undefined || item.data === null, + }; +} + +function appendYieldSection( + sections: Record, + sectionCounts: Map, + label: string, + value: unknown, + forceArray: boolean, +): void { + const count = sectionCounts.get(label) ?? 0; + const existing = sections[label]; + if (count === 0) { + sections[label] = forceArray ? [value] : value; + } else if (Array.isArray(existing)) { + existing.push(value); + } else { + sections[label] = [existing, value]; + } + sectionCounts.set(label, count + 1); +} + +/** True when `value` is a JSON-schema node whose instances are arrays. */ +function isArrayTypedSchema(value: unknown): boolean { + if (value === null || typeof value !== "object") return false; + const record = value as Record; + if (record.type === "array") return true; + if (Array.isArray(record.type) && record.type.includes("array")) return true; + for (const key of ["anyOf", "oneOf", "allOf"] as const) { + const variants = record[key]; + if (Array.isArray(variants) && variants.some(isArrayTypedSchema)) return true; + } + return false; +} + +/** + * Top-level output-schema property names declared as arrays (JTD `elements` → + * JSON `type: "array"`). An incremental yield section for such a label + * accumulates into a list even when the agent emits exactly one — otherwise a + * single `type: ["findings"]` yield would assemble as a bare object and fail + * array-typed schema validation. + */ +export function arrayValuedLabels(outputSchema: unknown): ReadonlySet { + const labels = new Set(); + // Use the JTD-converted JSON Schema (matches what validation runs against): + // JTD `optionalProperties.findings.elements` becomes `properties.findings` + // with `type: "array"`, which raw `normalizeSchema` would not expose. + const { jsonSchema } = buildOutputValidator(outputSchema); + if (jsonSchema === undefined) return labels; + const properties = jsonSchema.properties; + if (properties === null || typeof properties !== "object") return labels; + const propRecord = properties as Record; + for (const key in propRecord) { + if (isArrayTypedSchema(propRecord[key])) labels.add(key); + } + return labels; +} + +/** + * Assemble typed yield calls into the final payload consumed by schema validation. + * + * A non-empty array `type` contributes an incremental section and never decides + * termination by itself. A string `type` with omitted `data` makes the last + * assistant turn the raw terminal result. Other string-typed yields contribute + * the terminal labelled section. Untyped terminal yields keep the historical + * "last yield wins" behavior unless no terminal yield exists, in which case + * accumulated typed sections finalize on idle. + */ +export function assembleYieldResult( + yieldItems: YieldItem[], + lastAssistantText?: string, + arrayLabels?: ReadonlySet, +): AssembledYieldResult | undefined { + if (yieldItems.length === 0) return undefined; + let terminalItem: YieldItem | undefined; + for (let index = yieldItems.length - 1; index >= 0; index--) { + const item = yieldItems[index]; + if (!item) continue; + if (!isIncrementalYieldType(item.type)) { + terminalItem = item; + break; + } + } + let hasTypedSections = false; + for (const item of yieldItems) { + if (getYieldLabels(item.type).length > 0) { + hasTypedSections = true; + break; + } + } + if (terminalItem && typeof terminalItem.type === "string" && terminalItem.data === undefined) { + const resolved = resolveYieldPayload(terminalItem, lastAssistantText, getYieldLabels(terminalItem.type)); + return { + data: resolved.value, + schemaOverridden: terminalItem.schemaOverridden === true, + rawText: resolved.fromLastAssistantText && typeof resolved.value === "string", + missingData: resolved.missingData, + }; + } + if (!hasTypedSections && terminalItem) { + const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []); + return { + data: resolved.value, + schemaOverridden: terminalItem.schemaOverridden === true, + rawText: resolved.fromLastAssistantText && typeof resolved.value === "string", + missingData: resolved.missingData, + }; + } + + const sections: Record = {}; + const sectionCounts = new Map(); + let schemaOverridden = false; + let missingData = false; + let hasSections = false; + + for (const item of yieldItems) { + if (item.status === "aborted") continue; + schemaOverridden ||= item.schemaOverridden === true; + const labels = getYieldLabels(item.type); + if (labels.length === 0) continue; + const resolved = resolveYieldPayload(item, lastAssistantText, labels); + missingData ||= resolved.missingData; + const incremental = isIncrementalYieldType(item.type); + for (const label of labels) { + appendYieldSection( + sections, + sectionCounts, + label, + resolved.value, + incremental && (arrayLabels?.has(label) ?? false), + ); + hasSections = true; + } + if (!isIncrementalYieldType(item.type)) break; + } + + if (hasSections) { + return { data: sections, schemaOverridden, rawText: false, missingData }; + } + + if (!terminalItem) return undefined; + const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []); + return { + data: resolved.value, + schemaOverridden: terminalItem.schemaOverridden === true, + rawText: resolved.fromLastAssistantText && typeof resolved.value === "string", + missingData: resolved.missingData, + }; +} diff --git a/packages/coding-agent/src/tools/renderers.ts b/packages/coding-agent/src/tools/renderers.ts index cbcdf46e0..32e7136eb 100644 --- a/packages/coding-agent/src/tools/renderers.ts +++ b/packages/coding-agent/src/tools/renderers.ts @@ -88,7 +88,13 @@ export const toolRenderers: Record = { reflect: reflectToolRenderer as ToolRenderer, search_tool_bm25: searchToolBm25Renderer as ToolRenderer, ssh: sshToolRenderer as ToolRenderer, - task: taskToolRenderer as ToolRenderer, + // Lazy getter: `taskToolRenderer` lives in a module that closes an import + // cycle back here (task/renderer → task/render → … → tools/renderers), so + // reading it at init order-dependently hits its temporal dead zone. Deferring + // the read to first access (render time) sidesteps the cycle entirely. + get task(): ToolRenderer { + return taskToolRenderer as ToolRenderer; + }, todo: todoToolRenderer as ToolRenderer, github: githubToolRenderer as ToolRenderer, goal: goalToolRenderer as ToolRenderer, diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 355092322..d3131915c 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -510,10 +510,6 @@ describe("remote compaction setting", () => { ); const fetchSpy = mockFetch(fetchHandler); const completeSimpleSpy = vi.spyOn(ai, "completeSimple"); - completeSimpleSpy - .mockResolvedValueOnce(createAssistantMessage("History summary")) - .mockResolvedValueOnce(createAssistantMessage("Turn prefix summary")) - .mockResolvedValueOnce(createAssistantMessage("Short summary")); const result = await compact(preparation, model, "test-api-key", undefined, undefined, { fetch: fetchSpy, @@ -534,7 +530,10 @@ describe("remote compaction setting", () => { item => item.type === "reasoning" && item.encrypted_content === "encrypted_reasoning_turn_1", ), ).toBe(true); - expect(result.summary).toContain("History summary"); + // V1 now matches V2: the provider-native replay is preserved and local + // summarization is skipped (no redundant LLM round), leaving the placeholder. + expect(result.summary).toContain("Remote compaction preserved provider-native history"); + expect(completeSimpleSpy).not.toHaveBeenCalled(); expect(result.preserveData).toEqual({ openaiRemoteCompaction: { provider: "openai", diff --git a/packages/coding-agent/test/modes/controllers/event-controller-args-reveal.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-args-reveal.test.ts index c671c2b0f..c87a9f862 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-args-reveal.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-args-reveal.test.ts @@ -52,6 +52,7 @@ function createFixture(streamingMessage: AssistantMessage) { streamingComponent: { updateContent: vi.fn(), markTranscriptBlockFinalized: vi.fn() }, streamingMessage, pendingTools, + noteDisplayableThinkingContent: vi.fn(() => false), chatContainer: { addChild: vi.fn() }, toolOutputExpanded: false, session: { getToolByName: () => undefined }, diff --git a/packages/coding-agent/test/modes/controllers/event-controller-interrupt.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-interrupt.test.ts index e69f7866b..9118e64c7 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-interrupt.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-interrupt.test.ts @@ -16,7 +16,7 @@ function createContext() { const ctx = { isInitialized: true, settings: { get: () => false }, - statusLine: { invalidate: vi.fn() }, + statusLine: { invalidate: vi.fn(), markActivityStart: vi.fn(), markActivityEnd: vi.fn() }, updateEditorTopBorder: vi.fn(), pendingTools, hideThinkingBlock: false, diff --git a/packages/coding-agent/test/modes/controllers/event-controller-loader-recovery.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-loader-recovery.test.ts index 254cfb58f..84c01845c 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-loader-recovery.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-loader-recovery.test.ts @@ -47,7 +47,7 @@ function createContext(options: { terminalProgress?: boolean } = {}) { settings: { get: (path: string) => path === "terminal.showProgress" && options.terminalProgress === true, }, - statusLine: { invalidate: vi.fn() }, + statusLine: { invalidate: vi.fn(), markActivityStart: vi.fn(), markActivityEnd: vi.fn() }, updateEditorTopBorder: vi.fn(), pendingTools: new Map(), hideThinkingBlock: false, diff --git a/packages/coding-agent/test/modes/controllers/event-controller-read-grouping.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-read-grouping.test.ts index 57ca09120..1c1e660e4 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-read-grouping.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-read-grouping.test.ts @@ -77,6 +77,7 @@ function createFixture() { ui: { requestRender: vi.fn(), imageBudget: undefined }, chatContainer, pendingTools: new Map(), + noteDisplayableThinkingContent: vi.fn(() => false), settings: { get: () => false }, toolOutputExpanded: false, hideThinkingBlock: false, diff --git a/packages/coding-agent/test/modes/controllers/event-controller-superseded-agent-end.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-superseded-agent-end.test.ts index 36e05626b..ee21ff80a 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-superseded-agent-end.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-superseded-agent-end.test.ts @@ -17,7 +17,7 @@ function createContext() { const ctx = { isInitialized: true, settings: { get: () => false }, - statusLine: { invalidate: vi.fn() }, + statusLine: { invalidate: vi.fn(), markActivityStart: vi.fn(), markActivityEnd: vi.fn() }, updateEditorTopBorder: vi.fn(), pendingTools: new Map(), hideThinkingBlock: false, diff --git a/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts index 58858da84..cd89df02b 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts @@ -55,6 +55,7 @@ function createFixture(streamingMessage: AssistantMessage) { streamingComponent, streamingMessage, pendingTools: new Map(), + noteDisplayableThinkingContent: vi.fn(() => false), chatContainer: { addChild: vi.fn() }, toolOutputExpanded: false, settings, diff --git a/packages/coding-agent/test/session-manager-cwd-adoption.test.ts b/packages/coding-agent/test/session-manager-cwd-adoption.test.ts index 310fb994e..4044dd857 100644 --- a/packages/coding-agent/test/session-manager-cwd-adoption.test.ts +++ b/packages/coding-agent/test/session-manager-cwd-adoption.test.ts @@ -56,9 +56,18 @@ describe("SessionManager cwd adoption on resume", () => { // Simulate a legacy session whose header predates the cwd field. const raw = await Bun.file(fileB).text(); const lines = raw.split("\n").filter(Boolean); - const header = JSON.parse(lines[0]) as Record; + // The fixed-width title slot is line 0 now; edit the session header itself. + const headerIndex = lines.findIndex(line => { + try { + const parsed = JSON.parse(line) as Record; + return parsed.type === "session"; + } catch { + return false; + } + }); + const header = JSON.parse(lines[headerIndex]) as Record; header.cwd = ""; - lines[0] = JSON.stringify(header); + lines[headerIndex] = JSON.stringify(header); await Bun.write(fileB, `${lines.join("\n")}\n`); const launchDir = path.join(projectA, "sessions"); diff --git a/packages/coding-agent/test/session-manager-immediate-persist.test.ts b/packages/coding-agent/test/session-manager-immediate-persist.test.ts index a67c42aea..6aea97787 100644 --- a/packages/coding-agent/test/session-manager-immediate-persist.test.ts +++ b/packages/coding-agent/test/session-manager-immediate-persist.test.ts @@ -45,7 +45,8 @@ function readJsonl(file: string): Array> { .trimEnd() .split("\n") .filter(Boolean) - .map(line => JSON.parse(line) as Record); + .map(line => JSON.parse(line) as Record) + .filter(entry => entry.type !== "title"); } function messageRole(entry: Record): string | undefined { diff --git a/packages/coding-agent/test/session-manager/rewrite-rename-eperm.test.ts b/packages/coding-agent/test/session-manager/rewrite-rename-eperm.test.ts index 072cd8cde..4934806ac 100644 --- a/packages/coding-agent/test/session-manager/rewrite-rename-eperm.test.ts +++ b/packages/coding-agent/test/session-manager/rewrite-rename-eperm.test.ts @@ -62,8 +62,12 @@ describe("SessionManager rewrite EPERM replacement fallback", () => { const sessionFile = session.getSessionFile(); if (!sessionFile) throw new Error("Expected session file"); - storage.failNextSessionReplace = true; + // setSessionName now overlays the title slot in place; force a full rewrite + // (as compaction/shake do) so the tmp→jsonl replace of the existing file + // hits EPERM and exercises the atomic-write fallback. await expect(session.setSessionName("renamed session", "user")).resolves.toBe(true); + storage.failNextSessionReplace = true; + await expect(session.rewriteEntries()).resolves.toBeUndefined(); const rewritten = await storage.readText(sessionFile); expect(rewritten).toContain('"title":"renamed session"'); @@ -118,7 +122,7 @@ describe("SessionManager rewrite EPERM rollback failure", () => { let thrown: Error | undefined; try { - await session.setSessionName("doomed", "user"); + await session.rewriteEntries(); } catch (err) { thrown = err as Error; }