Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
45 changes: 39 additions & 6 deletions packages/coding-agent/src/session/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4312,10 +4312,36 @@ export class AgentSession {
this.#coordinatorToolObservations.set(event, Object.freeze({ label, observedAt: new Date().toISOString() }));
}

#canonicalMessageAdmissionTail: Promise<void> = Promise.resolve();

#reserveCanonicalMessageAdmission(
event: AgentEvent,
): { predecessor: Promise<void>; release: () => void } | undefined {
if (event.type !== "message_end") return undefined;
const predecessor = this.#canonicalMessageAdmissionTail;
const settled = Promise.withResolvers<void>();
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<void> => {
// 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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -4584,7 +4611,11 @@ export class AgentSession {
}

/** Internal handler for agent events - shared by subscribe and reconnect */
#handleAgentEvent = async (event: AgentEvent, activePromptHandle?: string): Promise<void> => {
#handleAgentEvent = async (
event: AgentEvent,
activePromptHandle?: string,
canonicalAdmission?: { predecessor: Promise<void>; release: () => void },
): Promise<void> => {
const attemptScope = (event as AgentEvent & { scope?: AttemptScope }).scope;

if (
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve message lifecycle order for uncontended admissions

When #flushPendingBackgroundExchanges synchronously emits message_start/message_end pairs for multiple custom messages (agent-session.ts:19274-19281), this unconditional await yields even when the predecessor is already resolved. The next message's start is therefore delivered before the prior message's end, producing start1, start2, end1, end2 for session, SDK, and extension subscribers instead of properly paired lifecycles. Avoid yielding for an uncontended admission, or include the associated lifecycle events in the ordered lane.

Useful? React with 👍 / 👎.

if (
(event.message.role === "hookMessage" || event.message.role === "custom") &&
!(event.message.role === "custom" && event.message.customType === "hindsight-recall")
Expand Down Expand Up @@ -4830,6 +4862,7 @@ export class AgentSession {
}
}
}
canonicalAdmission?.release();
}

// Deobfuscate assistant message content for display emission — the LLM echoes back
Expand Down
57 changes: 47 additions & 10 deletions packages/coding-agent/test/agent-session-midrun-compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
}),
Expand All @@ -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<AgentEvent, { type: "agent_end" }> =>
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();
Comment on lines +664 to +666

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Assert chronology in the canonical context

When compaction persists the split messages in the wrong order, these presence-only checks still pass; the chronology assertion below examines transient loop.agentEvents, not canonicalMessages, so it cannot detect a durable Assistant(preamble) -> Assistant(continuation) -> ToolResult regression that affects subsequent display/model context. Fresh evidence in this revision is that lines 667–681 add provider-event ordering while the canonical checks remain presence-only; compare the three canonical indices and require preamble < server result < continuation.

AGENTS.md reference: AGENTS.md:L156-L160

Useful? React with 👍 / 👎.

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"]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pair the red ordering assertion with its production fix

This assertion deterministically fails against the production code in this commit: the server-result message_end handler awaits #queuePreAdmissionArtifactSpill in agent-session.ts:4609-4613, while the synchronously emitted continuation enters another handler and reaches sessionManager.appendMessage first at agent-session.ts:4717-4749. The canonical order is therefore preamble, continuation, server result—not the expected sequence here—so this commit makes the focused test and any CI shard containing it red until the separately owned persistence-serialization fix lands. Include that fix with this assertion or defer the assertion.

Useful? React with 👍 / 👎.

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);
Comment on lines +692 to +695

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Assert the split messages survive canonical compaction

In this revision, the positive Cursor-message assertions have been removed altogether: this condition only proves that the unsplit anchor is absent. If compaction drops the server-side tool result, the continuation, or every Cursor message, the maintenance event and compaction entry still exist and this test passes, so it no longer protects the externally observable preservation contract it is intended to cover. Assert that the canonical context contains the split preamble, tool result, and continuation in provider order.

AGENTS.md reference: AGENTS.md:L156-L160

Useful? React with 👍 / 👎.

expect(canonicalMessages).not.toContainEqual(originalAnchor!);
expect(loop.events.filter(event => event.type === "agent_end")).toHaveLength(1);
expect(loop.streamCallCount()).toBe(2);
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading