diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fca891e0be..071dc6b979 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,6 +21,7 @@ - Updated every bundled GLM model profile (`glm-eco`, `glm-medium`, and `glm-pro`) from ZAI GLM-5.2 to GLM-5.3. - Defense-in-depth: when an Anthropic-origin assistant transcript message carrying directly adjacent `thinking`/`redacted_thinking` blocks is persisted, a single bounded warn is emitted per session manager instance — but only in development/test builds, never in production. The diagnostic names only the envelope shape (block count, adjacency presence, provider), never raw thinking text, signatures, redacted payloads, or transcript-path metadata. Storage is never mutated — the send-boundary collapse remains the wire source of truth; this is a read-only observation that helps surface upstream producers of the rejected shape (#4443). ### Fixed +- Cursor split responses now preserve canonical provider order across pre-admission artifact spilling, so a server-side tool result cannot be persisted after the assistant continuation that follows it. The canonical admission reservation is owned by each emission's own handler, so a host bridge or replay emitting the same `message_end` event object twice can no longer overwrite the reservation and deadlock every later canonical admission (#4536). - Default-model selection now reserves a causal fence before credential probing, so an already accepted prompt preflight cannot be overtaken and a selection accepted first blocks later prompt preflight through durable publication. The fence does not hold session admission across `waitForIdle`, allowing inherited auto-compaction continuations to obtain prompt admission; same-session reentrancy still fails fast, successor sessions remain protected, and disposal deterministically drains accepted selections while rejecting queued prompts without unhandled rejections (#4519). - Ordinary sessions no longer import or execute Claude Code and Codex directory hooks as competing runtime authorities. Runtime hook discovery is fail-closed to canonical native `.gjc/hooks/` providers while explicit configured paths, constrained plugin hooks, and foreign-provider import/diagnostic discovery remain available (#4516). - `gjc_coordinator_stop_session` no longer reports `close_failed` after a successful DR-1 terminal close. Reap now proves the same retained session is `terminal` and non-`live` for the exact `workspace/generation/incarnation` before completing local cleanup; rotated generation, different incarnation, ambiguous, still-live, and `terminal_uncertain` remain fail-closed (#4431). diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index bf58a60726..047716b27a 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -4312,10 +4312,36 @@ export class AgentSession { this.#coordinatorToolObservations.set(event, Object.freeze({ label, observedAt: new Date().toISOString() })); } + #canonicalMessageAdmissionTail: Promise = Promise.resolve(); + + #reserveCanonicalMessageAdmission( + event: AgentEvent, + ): { predecessor: Promise; release: () => void } | undefined { + if (event.type !== "message_end") return undefined; + const predecessor = this.#canonicalMessageAdmissionTail; + const settled = Promise.withResolvers(); + let released = false; + const release = () => { + if (released) return; + released = true; + settled.resolve(); + }; + // The reservation is owned by this emission's handler: keying it by the + // event object would let a replayed/bridged duplicate emission overwrite + // it and leave the first handler awaiting a promise only its own handler + // will ever release. + this.#canonicalMessageAdmissionTail = settled.promise; + return { predecessor, release }; + } + #trackAgentEvent = (event: AgentEvent): Promise => { // First statement of the listener: the observation must precede every claim, // reservation, and async hop this handler performs. this.#observeCoordinatorToolEvent(event); + // Reserve canonical message order synchronously. Agent listeners are not + // awaited, so a tool-result spill may yield while a later continuation + // otherwise overtakes it in persisted/display context. + const canonicalAdmission = this.#reserveCanonicalMessageAdmission(event); const terminalOwner = event.type === "agent_end" ? getAgentTerminalOwnerContext(event) : undefined; const maintenanceCheckpoint = event.type === "agent_end" && event.stopReason === "maintenance" && event.maintenanceOutcome !== "aborted"; @@ -4369,14 +4395,15 @@ export class AgentSession { this.#postPromptLeases.set(eventLease.resourceRunId, eventLease); if (eventLease) { await this.#runResourceLeaseContext.run(eventLease, () => - this.#handleAgentEvent(event, activePromptHandle), + this.#handleAgentEvent(event, activePromptHandle, canonicalAdmission), ); } else { - await this.#handleAgentEvent(event, activePromptHandle); + await this.#handleAgentEvent(event, activePromptHandle, canonicalAdmission); } } catch (error) { logger.warn("Agent event handler failed", { event: event.type, error: String(error) }); } finally { + canonicalAdmission?.release(); if (eventLease) { const pendingAgentEnd = event.type === "agent_end" && !maintenanceCheckpoint && this.#pendingAgentEndEmit === event @@ -4584,7 +4611,11 @@ export class AgentSession { } /** Internal handler for agent events - shared by subscribe and reconnect */ - #handleAgentEvent = async (event: AgentEvent, activePromptHandle?: string): Promise => { + #handleAgentEvent = async ( + event: AgentEvent, + activePromptHandle?: string, + canonicalAdmission?: { predecessor: Promise; release: () => void }, + ): Promise => { const attemptScope = (event as AgentEvent & { scope?: AttemptScope }).scope; if ( @@ -4731,10 +4762,11 @@ export class AgentSession { this.#silentAbortPending = false; } - // Canonical persistence must happen synchronously before listener work can - // await: the EventStream FIFO drain then guarantees tool results and every - // steering message are in the branch before a maintenance rewrite starts. + // Canonical persistence follows synchronous message_end reservation order. + // Only the admission predecessor and this event's own pre-admission work are + // inside the lane; release before extension delivery and unrelated post-work. if (event.type === "message_end") { + await canonicalAdmission?.predecessor; if ( (event.message.role === "hookMessage" || event.message.role === "custom") && !(event.message.role === "custom" && event.message.customType === "hindsight-recall") @@ -4830,6 +4862,7 @@ export class AgentSession { } } } + canonicalAdmission?.release(); } // Deobfuscate assistant message content for display emission — the LLM echoes back diff --git a/packages/coding-agent/test/agent-session-midrun-compaction.test.ts b/packages/coding-agent/test/agent-session-midrun-compaction.test.ts index 283c218c54..25e55c4bca 100644 --- a/packages/coding-agent/test/agent-session-midrun-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-midrun-compaction.test.ts @@ -305,7 +305,7 @@ describe("AgentSession mid-run compaction (issue #2035)", () => { } await options.afterStreamStart?.(streamOptions); stream.push({ type: "done", reason: message.stopReason as never, message }); - })(); + })().catch(error => stream.fail(error)); }); return stream; }, @@ -580,6 +580,7 @@ describe("AgentSession mid-run compaction (issue #2035)", () => { const loop = await buildLoopSession({ extensionSource: shortCircuitExtensionSource(), + settings: { "compaction.keepRecentTokens": 100 }, publishTextStartBeforeAfterStreamStart: true, responder: call => { if (call === 1) { @@ -629,7 +630,7 @@ describe("AgentSession mid-run compaction (issue #2035)", () => { await seedLoop(loop.session, [ { role: "user", content: "earlier request", timestamp: Date.now() }, assistantFor(model, { - content: [{ type: "text", text: "earlier response" }], + content: [{ type: "text", text: `earlier response ${"x".repeat(4_000)}` }], totalTokens: 1_000, stopReason: "stop", }), @@ -638,25 +639,61 @@ describe("AgentSession mid-run compaction (issue #2035)", () => { await loop.session.waitForIdle(); expect(originalAnchor).toBeDefined(); - expect(loop.session.messages.includes(originalAnchor!)).toBe(false); - const cursorMessages = loop.agentEvents.flatMap(event => { + const maintenanceEvents = loop.agentEvents.filter( + (event): event is Extract => + event.type === "agent_end" && event.stopReason === "maintenance", + ); + expect(maintenanceEvents).toHaveLength(1); + expect(maintenanceEvents[0]?.maintenanceOutcome).toBe("compacted"); + expect(getLatestCompactionEntry(loop.session.sessionManager.getBranch())).not.toBeNull(); + + const canonicalMessages = loop.session.buildDisplaySessionContext().messages; + const canonicalPreamble = canonicalMessages.find( + message => + message.role === "assistant" && + JSON.stringify((message as { content?: unknown }).content ?? "").includes("Cursor preamble"), + ); + const canonicalContinuation = canonicalMessages.find( + message => + message.role === "assistant" && + JSON.stringify((message as { content?: unknown }).content ?? "").includes("and continuation"), + ); + const canonicalServerResult = canonicalMessages.find( + message => message.role === "toolResult" && message.toolCallId === "cursor-server-result", + ); + expect(canonicalPreamble).toBeDefined(); + expect(canonicalContinuation).toBeDefined(); + expect(canonicalServerResult).toBeDefined(); + const canonicalCursorOrder = canonicalMessages.flatMap(message => { + if (message === canonicalPreamble) return ["preamble"]; + if (message === canonicalServerResult) return ["server-result"]; + if (message === canonicalContinuation) return ["continuation"]; + return []; + }); + expect(canonicalCursorOrder).toEqual(["preamble", "server-result", "continuation"]); + const cursorEvents = loop.agentEvents.flatMap(event => { if (event.type !== "message_end") return []; const content = JSON.stringify((event.message as { content?: unknown }).content ?? ""); return content.includes("Cursor") || content.includes("and continuation") ? [event.message] : []; }); - expect(cursorMessages.map(message => message.role)).toEqual(["assistant", "toolResult", "assistant"]); - expect(JSON.stringify((cursorMessages[0] as { content?: unknown } | undefined)?.content)).toContain( + expect(cursorEvents.map(message => message.role)).toEqual(["assistant", "toolResult", "assistant"]); + expect(JSON.stringify((cursorEvents[0] as { content?: unknown } | undefined)?.content)).toContain( "Cursor preamble", ); - expect(JSON.stringify((cursorMessages[1] as { content?: unknown } | undefined)?.content)).toContain( + expect(JSON.stringify((cursorEvents[1] as { content?: unknown } | undefined)?.content)).toContain( "Cursor server-side result", ); - expect(JSON.stringify((cursorMessages[2] as { content?: unknown } | undefined)?.content)).toContain( + expect(JSON.stringify((cursorEvents[2] as { content?: unknown } | undefined)?.content)).toContain( "and continuation", ); expect( - loop.agentEvents.filter(event => event.type === "agent_end" && event.stopReason === "maintenance"), - ).toHaveLength(1); + canonicalMessages.some(message => { + if (message.role !== "assistant") return false; + const content = JSON.stringify((message as { content?: unknown }).content ?? ""); + return content.includes("Cursor preamble and continuation") && content.includes("cursor-call"); + }), + ).toBe(false); + expect(canonicalMessages).not.toContainEqual(originalAnchor!); expect(loop.events.filter(event => event.type === "agent_end")).toHaveLength(1); expect(loop.streamCallCount()).toBe(2); } finally { diff --git a/packages/coding-agent/test/agent-session-pre-admission-artifact-spill.test.ts b/packages/coding-agent/test/agent-session-pre-admission-artifact-spill.test.ts index c9aca54e8f..1a3a421486 100644 --- a/packages/coding-agent/test/agent-session-pre-admission-artifact-spill.test.ts +++ b/packages/coding-agent/test/agent-session-pre-admission-artifact-spill.test.ts @@ -235,4 +235,50 @@ describe("AgentSession pre-admission artifact spill", () => { expect(persisted.message).toEqual(toolResult); expect(JSON.stringify(persisted.message)).not.toContain("artifact://"); }); + it("keeps canonical admission live when the same message_end event object is emitted twice", async () => { + tempDir = TempDir.createSync("@gjc-pre-admission-spill-duplicate-"); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled Anthropic model"); + const agent = new Agent({ + initialState: { + model: { ...model, contextWindow: 200_000, maxTokens: 128_000 }, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + }); + const sessionManager = SessionManager.inMemory(tempDir.path()); + session = new AgentSession({ + agent, + sessionManager, + settings: Settings.isolated({ "tools.preAdmissionArtifactSpill": true }), + modelRegistry: new ModelRegistry(authStorage), + }); + + const toolResult: ToolResultMessage = { + role: "toolResult", + toolCallId: "duplicate-emission", + toolName: "read", + content: [{ type: "text", text: "x".repeat(40_000) }], + isError: false, + timestamp: Date.now(), + }; + + // A host bridge or replay can hand the agent the same event object twice + // before the first emission's spill finishes. The admission reservation + // must stay owned by each emission's own handler, so both admissions run + // and no later canonical admission is blocked forever. + const duplicateEvent = { type: "message_end", message: toolResult } as const; + agent.emitExternalEvent(duplicateEvent); + agent.emitExternalEvent(duplicateEvent); + + await withTimeout(session.awaitSessionSettlement(), 5_000, "Duplicate emission deadlocked canonical admission"); + + const persistedToolResults = sessionManager + .getBranch() + .filter(entry => entry.type === "message" && entry.message.role === "toolResult"); + expect(persistedToolResults).toHaveLength(2); + }); });