From ea57e97220c65a0e23940903258e8ec8e8899471 Mon Sep 17 00:00:00 2001 From: luren Date: Thu, 30 Jul 2026 11:55:04 +0800 Subject: [PATCH 1/3] fix(acp): stream agent-initiated turns --- .../stream-agent-initiated-acp-turns.md | 5 + packages/acp-adapter/src/server.ts | 420 ++++- packages/acp-adapter/src/session.ts | 944 +++++++---- .../test/_helpers/real-engine-rig.ts | 420 +++++ .../test/agent-initiated-engine.e2e.test.ts | 201 +++ .../acp-adapter/test/approval-cancel.test.ts | 53 +- .../acp-adapter/test/approval-display.test.ts | 32 +- packages/acp-adapter/test/approval.test.ts | 46 +- packages/acp-adapter/test/e2e-fs.test.ts | 52 +- .../acp-adapter/test/e2e-happy-path.test.ts | 1426 ++++++++++++++++- .../acp-adapter/test/error-mapping.test.ts | 34 +- .../test/plan-and-commands.test.ts | 23 +- .../test/prompt-admission-v2.e2e.test.ts | 142 ++ .../acp-adapter/test/session-prompt.test.ts | 447 +++++- .../acp-adapter/test/session-slash.test.ts | 259 ++- .../acp-adapter/test/tool-call-stream.test.ts | 60 +- packages/acp-adapter/test/tool-result.test.ts | 23 +- .../agent-core-v2/docs/state-manifest.d.ts | 6 +- .../src/agent/contextMemory/types.ts | 1 + .../agent-core-v2/src/agent/rpc/core-api.ts | 2 + .../agent-core-v2/src/agent/rpc/rpcService.ts | 3 +- .../agent-core-v2/src/agent/skill/skill.ts | 1 + .../src/agent/skill/skillService.ts | 2 +- .../sessionLifecycleService.ts | 47 +- .../session/cron/sessionCronServiceImpl.ts | 1 - .../sessionLifecycle/sessionLifecycle.test.ts | 205 ++- .../agent-core/src/agent/context/types.ts | 1 + packages/agent-core/src/agent/index.ts | 2 +- packages/agent-core/src/agent/skill/index.ts | 2 +- packages/agent-core/src/rpc/core-api.ts | 2 + .../kap-server/src/protocol/events-zod.ts | 1 + packages/klient/src/contract/agent/rpc.ts | 1 + packages/klient/src/core/facade/agent.ts | 1 + packages/node-sdk/src/kimi-harness.ts | 34 + packages/node-sdk/src/rpc.ts | 60 +- packages/node-sdk/src/sdk-rpc-client-v2.ts | 36 +- packages/node-sdk/src/session.ts | 23 +- packages/node-sdk/src/v2/event-mapper.ts | 9 +- .../test/create-session-transport.test.ts | 257 ++- .../node-sdk/test/sdk-rpc-client-v2.test.ts | 153 ++ .../test/session-event-wiring.test.ts | 39 +- .../test/session-prompt-input.test.ts | 19 + packages/node-sdk/test/session-skills.test.ts | 6 +- packages/node-sdk/test/v1-v2-parity.test.ts | 75 +- .../protocol/src/__tests__/events.test.ts | 15 + packages/protocol/src/events.ts | 2 + 46 files changed, 5049 insertions(+), 544 deletions(-) create mode 100644 .changeset/stream-agent-initiated-acp-turns.md create mode 100644 packages/acp-adapter/test/_helpers/real-engine-rig.ts create mode 100644 packages/acp-adapter/test/agent-initiated-engine.e2e.test.ts create mode 100644 packages/acp-adapter/test/prompt-admission-v2.e2e.test.ts diff --git a/.changeset/stream-agent-initiated-acp-turns.md b/.changeset/stream-agent-initiated-acp-turns.md new file mode 100644 index 0000000000..315a8ddfe3 --- /dev/null +++ b/.changeset/stream-agent-initiated-acp-turns.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Stream agent-initiated turns to connected ACP sessions, preserve live updates while resuming, and keep later prompts responsive when hooks block before a turn starts. diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index a9ef407bee..068b834905 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -45,6 +45,7 @@ import { type Stream, } from '@agentclientprotocol/sdk'; import type { + Event, KimiConfig, KimiHarness, ModelAlias, @@ -58,7 +59,12 @@ import { LocalKaos, type Kaos } from '@moonshot-ai/kaos'; import { TERMINAL_AUTH_METHOD, buildTerminalAuthMethod } from './auth-methods'; import { redirectConsoleToStderr } from './log-guard'; import { AcpKaos } from './kaos-acp'; -import { AcpSession, type TelemetryTrackFn } from './session'; +import { + AcpSession, + getAcpSessionInteractionHandlers, + type AcpSessionInteractionHandlers, + type TelemetryTrackFn, +} from './session'; import { buildSessionConfigOptions } from './config-options'; import { availableCommandsUpdateNotification } from './events-map'; import { acpMcpServersToConfigs } from './mcp'; @@ -200,6 +206,15 @@ function nonEmptyString(value: string | undefined): string | undefined { return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; } +function requireCanonicalSessionId(value: string): string { + const normalized = nonEmptyString(value); + if (normalized !== undefined && normalized === value) return value; + throw RequestError.invalidParams( + { sessionId: value }, + 'sessionId must be non-empty and contain no surrounding whitespace', + ); +} + function effortStringOrUndefined(effort: unknown): string | undefined { if (typeof effort !== 'string') return undefined; const trimmed = effort.trim(); @@ -222,6 +237,9 @@ export class AcpServer implements Agent { private negotiated: AcpVersionSpec | undefined; private clientCapabilities: ClientCapabilities | undefined; private readonly sessions = new Map(); + private readonly pendingSessionSetupReleases = new Set<() => void>(); + private readonly sessionSetupTails = new Map>(); + private disposed = false; private readonly agentInfo: Implementation | undefined; private readonly terminalAuthEnv: Readonly> | undefined; private readonly terminalAuthLegacyCommand: string | undefined; @@ -302,6 +320,23 @@ export class AcpServer implements Agent { return this.sessions.get(sessionId); } + /** + * Release every session-lifetime ACP event bridge owned by this transport. + * The SDK sessions themselves belong to the harness and are closed by the + * runner's harness cleanup. + */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const release of this.pendingSessionSetupReleases) { + release(); + } + for (const session of this.sessions.values()) { + session.dispose(); + } + this.sessions.clear(); + } + async initialize(params: InitializeRequest): Promise { this.negotiated = negotiateVersion(params.protocolVersion); this.clientCapabilities = params.clientCapabilities; @@ -339,9 +374,11 @@ export class AcpServer implements Agent { } async newSession(params: NewSessionRequest): Promise { + this.assertNotDisposed(); if (!(await harnessIsAuthed(this.harness))) { throw RequestError.authRequired(); } + this.assertNotDisposed(); // ACP's `cwd` maps to the SDK's `workDir`. `model`, `planMode`, and // similar fields are wired in Phase 8 (per PLAN D3) — Phase 3.2 keeps // the surface minimal. Phase 10.1 adds `mcpServers` forwarding so @@ -373,7 +410,9 @@ export class AcpServer implements Agent { // the same reference, no AsyncLocalStorage needed. const sessionId = `session_${randomUUID()}`; const acpKaos = await this.maybeBuildAcpKaos(sessionId); + this.assertNotDisposed(); const persistenceKaos = acpKaos === undefined ? undefined : await this.ensureInnerKaos(); + this.assertNotDisposed(); const session = await this.harness.createSession({ id: sessionId, workDir: params.cwd, @@ -385,18 +424,11 @@ export class AcpServer implements Agent { // forwards via spread. See block comment above. mcpServers, }); + this.assertNotDisposed(); const currentModelId = await this.resolveCurrentModelId(); + this.assertNotDisposed(); const currentThinkingEffort = await this.resolveCurrentThinkingEffort(session); - const acpSession = new AcpSession( - this.conn, - session, - this.clientCapabilities, - this.makeTelemetryTrack(), - currentModelId, - this.harness, - currentThinkingEffort, - ); - this.sessions.set(session.id, acpSession); + this.assertNotDisposed(); // Phase 14 (PLAN D11) advertises both the model and mode pickers as // a unified `configOptions: SessionConfigOption[]` surface. The // dedicated Phase 12 `modes:` field is gone — see @@ -415,6 +447,25 @@ export class AcpServer implements Agent { currentThinkingEffort, DEFAULT_MODE_ID, ); + this.assertNotDisposed(); + + // A new session id is not usable by the client until this request + // returns it. Finish every asynchronous setup step before attaching the + // session-lifetime bridge so an autonomous update cannot overtake the + // `session/new` response on the shared JSON-RPC stream. New sessions + // cannot have pre-existing task or cron work, so this ordering does not + // discard an already-running turn. + this.sessions.get(session.id)?.dispose(); + const acpSession = new AcpSession( + this.conn, + session, + this.clientCapabilities, + this.makeTelemetryTrack(), + currentModelId, + this.harness, + currentThinkingEffort, + ); + this.sessions.set(session.id, acpSession); this.scheduleAvailableCommandsUpdate(session.id); return { sessionId: session.id, @@ -444,22 +495,25 @@ export class AcpServer implements Agent { * {@link setupSessionFromExisting}; the ONE differentiator is that * `loadSession` calls `replayHistory()` here, whereas `resumeSession` * deliberately skips it (per ACP spec G4 / plan gap-4.3). - */ + */ async loadSession(params: LoadSessionRequest): Promise { - const { session, acpSession, configOptions } = await this.setupSessionFromExisting({ - cwd: params.cwd, - sessionId: params.sessionId, - mcpServers: params.mcpServers, - mode: 'load', + const sessionId = requireCanonicalSessionId(params.sessionId); + return this.withSessionSetupLock(sessionId, async () => { + const { session, acpSession, configOptions } = await this.setupSessionFromExisting({ + cwd: params.cwd, + sessionId, + mcpServers: params.mcpServers, + mode: 'load', + }); + // Synchronously replay history — the response must not settle + // until every historical `session/update` has been pushed, + // otherwise the client would race the load completion against + // its own UI bootstrap. This is the ONE difference vs. + // `resumeSession`, which intentionally omits this step. + await acpSession.replayHistory(); + this.scheduleAvailableCommandsUpdate(session.id); + return { configOptions }; }); - // Synchronously replay history — the response must not settle - // until every historical `session/update` has been pushed, - // otherwise the client would race the load completion against - // its own UI bootstrap. This is the ONE difference vs. - // `resumeSession`, which intentionally omits this step. - await acpSession.replayHistory(); - this.scheduleAvailableCommandsUpdate(session.id); - return { configOptions }; } /** @@ -480,16 +534,19 @@ export class AcpServer implements Agent { * (a) telemetry mode is `'resume'` (vs `'load'`), and (b) no * `replayHistory()` call. See plan G4 (lines 106-170) for the * rationale, and gap-4.1 for the matching capability advertisement. - */ + */ async resumeSession(params: ResumeSessionRequest): Promise { - const { session, configOptions } = await this.setupSessionFromExisting({ - cwd: params.cwd, - sessionId: params.sessionId, - mcpServers: params.mcpServers, - mode: 'resume', + const sessionId = requireCanonicalSessionId(params.sessionId); + return this.withSessionSetupLock(sessionId, async () => { + const { session, configOptions } = await this.setupSessionFromExisting({ + cwd: params.cwd, + sessionId, + mcpServers: params.mcpServers, + mode: 'resume', + }); + this.scheduleAvailableCommandsUpdate(session.id); + return { configOptions }; }); - this.scheduleAvailableCommandsUpdate(session.id); - return { configOptions }; } /** @@ -525,9 +582,11 @@ export class AcpServer implements Agent { acpSession: AcpSession; configOptions: SessionConfigOption[]; }> { + this.assertNotDisposed(); if (!(await harnessIsAuthed(this.harness))) { throw RequestError.authRequired(); } + this.assertNotDisposed(); if (!this.conn) { throw RequestError.internalError(undefined, 'AcpServer is missing its AgentSideConnection'); } @@ -542,7 +601,35 @@ export class AcpServer implements Agent { // kernel. const mcpServers = acpMcpServersToConfigs(params.mcpServers); const acpKaos = await this.maybeBuildAcpKaos(params.sessionId); + this.assertNotDisposed(); const persistenceKaos = acpKaos === undefined ? undefined : await this.ensureInnerKaos(); + this.assertNotDisposed(); + const initialSessionEvents: Event[] = []; + const settleInitialInteractions = this.prepareSessionInteractionBridge( + params.sessionId, + ); + let releaseInitialSessionEvents = (): void => undefined; + if ( + params.mode === 'resume' && + typeof this.harness.onSessionEvent === 'function' + ) { + try { + const unsubscribe = this.harness.onSessionEvent(params.sessionId, (event) => { + initialSessionEvents.push(event); + }); + let active = true; + releaseInitialSessionEvents = (): void => { + if (!active) return; + active = false; + this.pendingSessionSetupReleases.delete(releaseInitialSessionEvents); + unsubscribe(); + }; + this.pendingSessionSetupReleases.add(releaseInitialSessionEvents); + } catch (error) { + settleInitialInteractions(); + throw error; + } + } let session: Session; try { session = await this.harness.resumeSession({ @@ -554,7 +641,10 @@ export class AcpServer implements Agent { // kernel-only field that the SDK forwards via spread. mcpServers, }); + this.assertNotDisposed(); } catch (err) { + releaseInitialSessionEvents(); + settleInitialInteractions(); // Surface unknown-session as invalid_params so the JSON-RPC layer // returns a structured failure rather than a generic internal // error. Other errors propagate as-is. @@ -567,47 +657,219 @@ export class AcpServer implements Agent { } throw err; } - // Phase 14 (PLAN D11) — same `configOptions:` advertisement as - // `newSession`. `currentModeId` is `default` on every load (mode - // is session-scoped per PLAN D9); `currentModelId` is read from - // the resumed session's main-agent config when available so the - // dropdown's highlight matches the model the resumed turn will - // actually use — falling back to the harness-level default - // resolution when the resume state lacks a `modelAlias`. - const resumeState = session.getResumeState?.(); - const resumedModelAlias = resumeState?.agents?.['main']?.config?.modelAlias; - const currentModelId = - typeof resumedModelAlias === 'string' && resumedModelAlias.length > 0 - ? resumedModelAlias - : await this.resolveCurrentModelId(); - // The resumed thinking effort is read off the main-agent config and - // carried through as-is — it is the engine-resolved value - // (`'off'`, `'on'`, or a declared level), which the thinking picker - // projects onto its row set. Falls back to the live session status, - // then the harness-level default, when the resume state lacks the - // field. - const resumedThinkingEffort = resumeState?.agents?.['main']?.config?.thinkingEffort; - const currentThinkingEffort = await this.resolveCurrentThinkingEffort( - session, - resumedThinkingEffort, - ); - const acpSession = new AcpSession( - this.conn, - session, - this.clientCapabilities, - this.makeTelemetryTrack(), - currentModelId, - this.harness, - currentThinkingEffort, - ); - this.sessions.set(session.id, acpSession); - const configOptions = await buildSessionConfigOptions( - this.harness, - currentModelId, - currentThinkingEffort, - DEFAULT_MODE_ID, + let createdAcpSession: AcpSession | undefined; + + try { + // A cold ACP resume can race events emitted while the SDK is still + // materializing its Session object. Its temporary harness subscription + // captures only live events for this known id. Transfer that FIFO into + // AcpSession synchronously, before config lookup. Load deliberately does + // not use this relay: history replay needs a producer-level atomic + // snapshot/live boundary rather than a consumer-side guessed cut. + const resumeState = session.getResumeState?.(); + const resumedModelAlias = resumeState?.agents?.['main']?.config?.modelAlias; + const initialModelId = + typeof resumedModelAlias === 'string' && resumedModelAlias.length > 0 + ? resumedModelAlias + : undefined; + const resumedThinkingEffort = resumeState?.agents?.['main']?.config?.thinkingEffort; + const initialThinkingEffort = effortStringOrUndefined(resumedThinkingEffort); + const existingAcpSession = this.sessions.get(session.id); + let acpSession: AcpSession; + if (existingAcpSession !== undefined) { + // An existing adapter already owns an SDK event subscription for this + // session id, even if the harness returns a replacement Session + // object. It observed the same transport events as the temporary raw + // listener, so never replay that parallel copy. + releaseInitialSessionEvents(); + initialSessionEvents.splice(0); + if (existingAcpSession.session === session) { + acpSession = existingAcpSession; + } else { + existingAcpSession.dispose(); + acpSession = new AcpSession( + this.conn, + session, + this.clientCapabilities, + this.makeTelemetryTrack(), + initialModelId, + this.harness, + initialThinkingEffort, + ); + this.sessions.set(session.id, acpSession); + createdAcpSession = acpSession; + } + } else { + releaseInitialSessionEvents(); + acpSession = new AcpSession( + this.conn, + session, + this.clientCapabilities, + this.makeTelemetryTrack(), + initialModelId, + this.harness, + initialThinkingEffort, + initialSessionEvents, + ); + initialSessionEvents.splice(0); + this.sessions.set(session.id, acpSession); + createdAcpSession = acpSession; + } + + // Phase 14 (PLAN D11) — same `configOptions:` advertisement as + // `newSession`. `currentModeId` is `default` on every load (mode + // is session-scoped per PLAN D9); `currentModelId` is read from + // the resumed session's main-agent config when available so the + // dropdown's highlight matches the model the resumed turn will + // actually use — falling back to the harness-level default + // resolution when the resume state lacks a `modelAlias`. + const currentModelId = initialModelId ?? (await this.resolveCurrentModelId()); + this.assertNotDisposed(); + // The resumed thinking effort is read off the main-agent config and + // carried through as-is — it is the engine-resolved value + // (`'off'`, `'on'`, or a declared level), which the thinking picker + // projects onto its row set. Falls back to the live session status, + // then the harness-level default, when the resume state lacks the + // field. + const currentThinkingEffort = await this.resolveCurrentThinkingEffort( + session, + resumedThinkingEffort, + ); + this.assertNotDisposed(); + acpSession.setInitialConfigState(currentModelId, currentThinkingEffort); + const configOptions = await buildSessionConfigOptions( + this.harness, + currentModelId, + currentThinkingEffort, + DEFAULT_MODE_ID, + ); + this.assertNotDisposed(); + settleInitialInteractions(getAcpSessionInteractionHandlers(acpSession)); + return { session, acpSession, configOptions }; + } catch (error) { + releaseInitialSessionEvents(); + initialSessionEvents.splice(0); + // A failed load/resume request must not leave behind the bridge this + // invocation installed. Preserve an adapter that was already attached + // to the exact same live Session: it belongs to an earlier successful + // request and remains responsible for that session's updates. + if ( + createdAcpSession !== undefined && + this.sessions.get(session.id) === createdAcpSession + ) { + this.sessions.delete(session.id); + createdAcpSession.dispose(); + } + settleInitialInteractions(); + throw error; + } + } + + private assertNotDisposed(): void { + if (!this.disposed) return; + throw RequestError.internalError( + undefined, + 'ACP transport closed before session setup completed', ); - return { session, acpSession, configOptions }; + } + + /** + * Hold cold-resume approval and question requests until AcpSession owns the + * reverse-RPC bridge. Autonomous work can begin before the SDK constructs + * its Session wrapper; without this handoff the base client safely but + * incorrectly cancels those interactions as unhandled. + */ + private prepareSessionInteractionBridge( + sessionId: string, + ): (handlers?: AcpSessionInteractionHandlers) => void { + if ( + this.sessions.has(sessionId) || + typeof this.harness.registerSessionApprovalHandler !== 'function' || + typeof this.harness.registerSessionQuestionHandler !== 'function' + ) { + return () => undefined; + } + + let resolveReady!: ( + handlers: AcpSessionInteractionHandlers | undefined, + ) => void; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + let releaseApproval = (): void => undefined; + let releaseQuestion = (): void => undefined; + try { + releaseApproval = this.harness.registerSessionApprovalHandler( + sessionId, + async (request) => { + const handlers = await ready; + if (handlers === undefined) { + return { + decision: 'cancelled', + feedback: 'ACP session setup did not complete.', + }; + } + return handlers.approval(request); + }, + ); + releaseQuestion = this.harness.registerSessionQuestionHandler( + sessionId, + async (request) => { + const handlers = await ready; + return handlers === undefined ? null : handlers.question(request); + }, + ); + } catch (error) { + releaseApproval(); + releaseQuestion(); + resolveReady(undefined); + throw error; + } + + let settled = false; + const settle = (handlers?: AcpSessionInteractionHandlers): void => { + if (settled) return; + settled = true; + this.pendingSessionSetupReleases.delete(settle); + resolveReady(handlers); + releaseApproval(); + releaseQuestion(); + }; + this.pendingSessionSetupReleases.add(settle); + return settle; + } + + /** + * Serialize load/resume setup for one session on this ACP connection. + * + * The ACP SDK dispatches requests concurrently. Without a keyed critical + * section, two cold resumes can both materialize SDK Session wrappers and + * race adapter replacement, config failure cleanup, and raw-event handoff. + * Different session ids remain independent. + */ + private async withSessionSetupLock( + sessionId: string, + run: () => Promise, + ): Promise { + const previous = this.sessionSetupTails.get(sessionId) ?? Promise.resolve(); + const waitForPrevious = previous.catch(() => undefined); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const tail = waitForPrevious.then(() => gate); + this.sessionSetupTails.set(sessionId, tail); + + await waitForPrevious; + try { + this.assertNotDisposed(); + return await run(); + } finally { + release(); + if (this.sessionSetupTails.get(sessionId) === tail) { + this.sessionSetupTails.delete(sessionId); + } + } } /** @@ -1050,8 +1312,16 @@ export async function runAcpServerWithStream( slashCommands?: SlashCommandsResolver; }, ): Promise { - const conn = new AgentSideConnection((c) => new AcpServer(harness, c, opts), stream); - await conn.closed; + let server: AcpServer | undefined; + const conn = new AgentSideConnection((c) => { + server = new AcpServer(harness, c, opts); + return server; + }, stream); + try { + await conn.closed; + } finally { + server?.dispose(); + } } /** diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 747b44ea9c..35f93d70c7 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto'; + import { RequestError, type AgentSideConnection, @@ -71,6 +73,50 @@ export type TelemetryTrackFn = ( properties?: Record, ) => void; +export interface AcpSessionInteractionHandlers { + readonly approval: (request: ApprovalRequest) => Promise; + readonly question: ( + request: QuestionRequest, + ) => Promise; +} + +const interactionHandlersBySession = new WeakMap< + AcpSession, + AcpSessionInteractionHandlers +>(); + +/** @internal Package-local bridge used while a cold ACP session is materializing. */ +export function getAcpSessionInteractionHandlers( + session: AcpSession, +): AcpSessionInteractionHandlers { + const handlers = interactionHandlersBySession.get(session); + if (handlers === undefined) { + throw new Error('ACP session interaction handlers are unavailable'); + } + return handlers; +} + +type PromptCorrelation = + | { readonly kind: 'user'; readonly promptId: string } + | { readonly kind: 'skill_activation'; readonly activationId: string }; + +interface PromptAdmission { + readonly sessionId: string; + readonly correlation: PromptCorrelation; + readonly kick: () => Promise; + readonly resolve: (response: PromptResponse) => void; + readonly reject: (error: unknown) => void; + turnId?: number; + kickStarted: boolean; + settled: boolean; + unsubscribe?: () => void; +} + +type TaskTerminationEvent = Extract< + Event, + { readonly type: 'background.task.terminated' | 'task.terminated' } +>; + /** * Adapter-side wrapper around a {@link Session} from the Kimi node SDK. * @@ -87,11 +133,11 @@ export class AcpSession { * `toolCallId` (`${turnId}:${rawId}`) so the client can correlate the * permission prompt with the tool card it has already rendered. * - * Updated inside the existing `onEvent` listener in {@link prompt} - * (any event carrying a numeric `turnId` advances the value), and - * reset to `undefined` on `turn.ended`. Approval flows are gated by - * the SDK on the active turn so a stale value is effectively - * unreachable in practice; the `undefined` fallback in + * Updated by the session-lifetime event bridge (any main-agent event + * carrying a numeric `turnId` advances the value), and reset to + * `undefined` on `turn.ended`. Approval flows are gated by the SDK on + * the active turn so a stale value is effectively unreachable in + * practice; the `undefined` fallback in * `buildPermissionToolCallUpdate` exists for defence-in-depth. */ private currentTurnId: number | undefined = undefined; @@ -156,6 +202,29 @@ export class AcpSession { // prompts are all covered rather than only the most recent. private readonly pendingPromptAborts = new Set<{ aborted: boolean }>(); + /** + * Session-lifetime event projection state. + * + * ACP permits the agent to send `session/update` notifications when no + * `session/prompt` request is in flight. The underlying SDK likewise emits + * one session event stream for both client-initiated and runtime-initiated + * turns, so update projection must live for the whole {@link AcpSession} + * lifetime rather than inside {@link prompt}. + */ + // Streaming tool arguments use replace-content semantics on ACP, so retain + // the cumulative text for the active turn. The wire-id set prevents a + // `tool.call.started` event from creating a second card when an earlier + // `tool.call.delta` already lazy-created it. + private readonly argsByToolCall = new Map(); + private readonly startedToolCalls = new Set(); + private readonly promptAdmissions: PromptAdmission[] = []; + private admittingPrompt: PromptAdmission | undefined; + private unsubscribeSessionEvents: (() => void) | undefined; + private unsubscribeApprovalHandler: (() => void) | undefined; + private unsubscribeQuestionHandler: (() => void) | undefined; + private queuedSessionEvents: Event[] | undefined; + private disposed = false; + /** * The most recent command palette advertised to the ACP client. Used by * `/help` so the response matches the client's `available_commands_update` @@ -211,9 +280,20 @@ export class AcpSession { * Defaults to `'off'` when absent. */ initialThinkingEffort?: string, + /** + * Live events captured for a known session id while a cold + * `session/resume` was materializing its SDK Session. The server + * transfers ownership of this FIFO synchronously, after releasing + * the temporary raw subscription and before any asynchronous setup. + */ + initialSessionEvents: readonly Event[] = [], ) { this.currentModelIdInternal = initialModelId ?? ''; this.currentThinkingEffortInternal = initialThinkingEffort ?? 'off'; + const approvalHandler = (request: ApprovalRequest) => + this.handleApproval(request); + const questionHandler = (request: QuestionRequest) => + this.handleQuestion(request); // Register the approval bridge once, at session-construction time — // NOT per-prompt — because `setApprovalHandler` is scoped to the // SDK session, not the individual turn. The handler captures `this` @@ -224,15 +304,40 @@ export class AcpSession { // tests may omit it. Treat absence as "no approval channel" rather // than crashing the constructor — the SDK still works end-to-end, // just without reverse-RPC approvals. - if (typeof this.session.setApprovalHandler === 'function') { - this.session.setApprovalHandler((req) => this.handleApproval(req)); - } - // Same pattern as the approval handler, but for the AskUserQuestion - // reverse-RPC channel (Phase 13.1). Pre-Phase-13 builds of the SDK - // do not expose `setQuestionHandler`, and unit-test stubs may omit - // it; the `typeof === 'function'` guard keeps both cases working. - if (typeof this.session.setQuestionHandler === 'function') { - this.session.setQuestionHandler(async (req) => this.handleQuestion(req)); + try { + if (typeof this.session.registerApprovalHandler === 'function') { + this.unsubscribeApprovalHandler = + this.session.registerApprovalHandler(approvalHandler); + } else if (typeof this.session.setApprovalHandler === 'function') { + this.session.setApprovalHandler(approvalHandler); + } + // Same pattern as the approval handler, but for the AskUserQuestion + // reverse-RPC channel (Phase 13.1). Pre-Phase-13 builds of the SDK + // do not expose `setQuestionHandler`, and unit-test stubs may omit + // it; the `typeof === 'function'` guard keeps both cases working. + if (typeof this.session.registerQuestionHandler === 'function') { + this.unsubscribeQuestionHandler = + this.session.registerQuestionHandler(questionHandler); + } else if (typeof this.session.setQuestionHandler === 'function') { + this.session.setQuestionHandler(questionHandler); + } + this.queuedSessionEvents = [...initialSessionEvents]; + this.unsubscribeSessionEvents = this.session.onEvent((event) => { + const queue = this.queuedSessionEvents; + if (queue !== undefined) { + queue.push(event); + return; + } + this.handleSessionEvent(event); + }); + this.drainQueuedSessionEvents(); + interactionHandlersBySession.set(this, { + approval: approvalHandler, + question: questionHandler, + }); + } catch (error) { + this.releaseOwnedRegistrations(); + throw error; } } @@ -269,6 +374,97 @@ export class AcpSession { return this.currentModeIdInternal; } + /** + * Finalize the model/thinking snapshot and reset the ACP mode after the + * server's asynchronous config resolution completes. + * + * A resumed session's event bridge is installed before asynchronous model + * metadata lookup, so setup starts with provisional values and reconciles + * them before the ACP response is returned. + */ + setInitialConfigState(modelId: string, thinkingEffort: string): void { + if (this.disposed) return; + this.currentModelIdInternal = modelId; + this.currentThinkingEffortInternal = thinkingEffort; + this.currentModeIdInternal = DEFAULT_MODE_ID; + } + + /** + * Release the session-lifetime event bridge. + * + * `AcpServer` calls this before replacing an adapter with a different SDK + * session and when its ACP transport closes. Idempotence matters because a + * reconnect can race the old connection's final cleanup. Production SDK + * Sessions return ownership-aware handler registrations, so releasing an + * old adapter cannot clear a newer adapter's replacement. Partial legacy + * test stubs still fall back to the setter-only surface. + */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.releaseOwnedRegistrations(); + for (const pending of this.pendingPromptAborts) { + pending.aborted = true; + } + while (this.promptAdmissions.length > 0) { + const admission = this.promptAdmissions.at(0); + if (admission === undefined) break; + this.rejectPromptAdmission( + admission, + RequestError.internalError( + { sessionId: admission.sessionId }, + 'ACP session transport closed before the prompt turn ended', + ), + ); + } + this.argsByToolCall.clear(); + this.startedToolCalls.clear(); + this.currentTurnId = undefined; + } + + private releaseOwnedRegistrations(): void { + interactionHandlersBySession.delete(this); + const releases = [ + this.unsubscribeSessionEvents, + this.unsubscribeApprovalHandler, + this.unsubscribeQuestionHandler, + ]; + this.unsubscribeSessionEvents = undefined; + this.unsubscribeApprovalHandler = undefined; + this.unsubscribeQuestionHandler = undefined; + this.queuedSessionEvents?.splice(0); + this.queuedSessionEvents = undefined; + for (const release of releases) { + try { + release?.(); + } catch (error) { + log.warn('acp: failed to release session registration', { + sessionId: this.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + + /** + * Drain the resume handoff FIFO after the Session listener is installed. + * + * The queue remains active while it is being drained, so a synchronous + * reentrant SDK event is appended and processed after all earlier events. + * JavaScript cannot run an unrelated task between the temporary raw + * unsubscribe and constructor call, which gives the server a no-gap + * handoff without changing the multicast semantics of Session.onEvent. + */ + private drainQueuedSessionEvents(): void { + const queue = this.queuedSessionEvents; + if (queue === undefined) return; + for (let index = 0; !this.disposed && index < queue.length; index += 1) { + this.handleSessionEvent(queue[index]!); + } + queue.splice(0); + this.queuedSessionEvents = undefined; + } + /** * Forward an ACP `session/cancel` notification to the underlying SDK * session. The SDK's `cancel()` is idempotent at the RPC layer, so @@ -569,7 +765,7 @@ export class AcpSession { * `toolCalls` entry. A monotonically increasing synthetic `turnId` * starts at 1 and bumps on each assistant message so the wire ids * (`${turnId}:${toolCallId}`) match the live emission scheme used - * in {@link runPromptBody}. + * in {@link handleSessionEvent}. * - role `tool` → `tool_call_update` with `status: 'completed'` * (or `'failed'` if the SDK marked the message as an error). * `toolCallId` is looked up from the bookkeeping map populated when @@ -586,7 +782,7 @@ export class AcpSession { * Errors thrown by individual `sessionUpdate` calls are caught and * logged so a single transient push failure does not truncate the * whole replay. The method awaits every push (unlike the live - * `runPromptBody` fire-and-forget path) because replay is a one-shot + * {@link handleSessionEvent} fire-and-forget path) because replay is a one-shot * batch — completion ordering is what tells the caller (`loadSession`) * that the response is safe to return. */ @@ -767,6 +963,197 @@ export class AcpSession { ); } + private isFromMainAgent(event: { agentId?: string }): boolean { + return event.agentId === undefined || event.agentId === MAIN_AGENT_ID; + } + + /** + * Project the SDK's session-wide event stream onto ACP updates. + * + * This listener is registered once in the constructor and remains active + * between `session/prompt` requests. Prompt request completion is handled by + * a separate, non-projecting listener in {@link runTurnBody}; keeping those + * responsibilities separate prevents prompt-driven turns from being emitted + * twice while still making runtime-initiated turns visible. + */ + private handleSessionEvent(event: Event): void { + if (this.disposed) return; + if (!this.isFromMainAgent(event)) return; + + if (event.type === 'background.task.terminated' || event.type === 'task.terminated') { + const text = taskCompletionDisplayText(event.info); + if (text !== undefined) { + this.emitAgentInitiatedUserMessage(text, 'background task'); + } + return; + } + if (event.type === 'cron.fired') { + this.emitAgentInitiatedUserMessage(event.prompt, 'cron'); + return; + } + if (event.type === 'turn.started') { + // A start is also a recovery boundary if an older turn's terminal event + // was lost: never carry partial tool projection state into the new turn. + this.argsByToolCall.clear(); + this.startedToolCalls.clear(); + this.currentTurnId = event.turnId; + return; + } + if ('turnId' in event && typeof event.turnId === 'number') { + this.currentTurnId = event.turnId; + } + + if (event.type === 'assistant.delta') { + this.conn + .sessionUpdate(assistantDeltaToSessionUpdate(this.id, event)) + .catch((error) => { + log.warn('acp: failed to push agent_message_chunk', { + sessionId: this.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + return; + } + if (event.type === 'thinking.delta') { + this.conn + .sessionUpdate(thinkingDeltaToSessionUpdate(this.id, event)) + .catch((error) => { + log.warn('acp: failed to push agent_thought_chunk', { + sessionId: this.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + return; + } + if (event.type === 'tool.call.started') { + this.argsByToolCall.set(event.toolCallId, { args: stringifyArgs(event.args) }); + const startedWireId = acpToolCallId(event.turnId, event.toolCallId); + if (this.startedToolCalls.has(startedWireId)) { + this.conn + .sessionUpdate(toolCallStartedUpgradeToSessionUpdate(this.id, event)) + .catch((error) => { + log.warn('acp: failed to push tool_call_update (start upgrade)', { + sessionId: this.id, + toolCallId: event.toolCallId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } else { + this.startedToolCalls.add(startedWireId); + this.conn + .sessionUpdate(toolCallStartToSessionUpdate(this.id, event)) + .catch((error) => { + log.warn('acp: failed to push tool_call', { + sessionId: this.id, + toolCallId: event.toolCallId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + if (event.display) { + const planNote = planFromDisplayBlock(this.id, event.turnId, event.display); + if (planNote !== null) { + this.conn.sessionUpdate(planNote).catch((error) => { + log.warn('acp: failed to push plan', { + sessionId: this.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + } + return; + } + if (event.type === 'tool.call.delta') { + const deltaWireId = acpToolCallId(event.turnId, event.toolCallId); + if (!this.startedToolCalls.has(deltaWireId)) { + const initial = event.argumentsPart ?? ''; + this.argsByToolCall.set(event.toolCallId, { args: initial }); + this.startedToolCalls.add(deltaWireId); + this.conn + .sessionUpdate(toolCallLazyCreateToSessionUpdate(this.id, event)) + .catch((error) => { + log.warn('acp: failed to push tool_call (lazy create from delta)', { + sessionId: this.id, + toolCallId: event.toolCallId, + error: error instanceof Error ? error.message : String(error), + }); + }); + return; + } + let accumulator = this.argsByToolCall.get(event.toolCallId); + if (accumulator === undefined) { + accumulator = { args: '' }; + this.argsByToolCall.set(event.toolCallId, accumulator); + } + this.conn + .sessionUpdate(toolCallDeltaToSessionUpdate(this.id, event, accumulator)) + .catch((error) => { + log.warn('acp: failed to push tool_call_update (delta)', { + sessionId: this.id, + toolCallId: event.toolCallId, + error: error instanceof Error ? error.message : String(error), + }); + }); + return; + } + if (event.type === 'tool.progress') { + const notification = toolProgressToSessionUpdate(this.id, event); + if (notification === null) return; + this.conn.sessionUpdate(notification).catch((error) => { + log.warn('acp: failed to push tool_call_update (progress)', { + sessionId: this.id, + toolCallId: event.toolCallId, + error: error instanceof Error ? error.message : String(error), + }); + }); + return; + } + if (event.type === 'tool.result') { + this.conn + .sessionUpdate(toolResultToSessionUpdate(this.id, event)) + .catch((error) => { + log.warn('acp: failed to push tool_call_update (result)', { + sessionId: this.id, + toolCallId: event.toolCallId, + error: error instanceof Error ? error.message : String(error), + }); + }); + return; + } + if (event.type === 'turn.ended') { + this.argsByToolCall.clear(); + this.startedToolCalls.clear(); + this.currentTurnId = undefined; + } + } + + /** + * Emit the display-safe user-side half of an autonomous turn. + * + * Runtime trigger messages in context are XML control payloads intended for + * the model, not UI text. Task lifecycle and cron events carry the stable + * public fields needed for ACP instead, so this projection never reads the + * context message or serializes its internal XML. + */ + private emitAgentInitiatedUserMessage(text: string, source: string): void { + if (text.length === 0) return; + this.conn + .sessionUpdate({ + sessionId: this.id, + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text }, + }, + }) + .catch((error) => { + log.warn('acp: failed to push autonomous user_message_chunk', { + sessionId: this.id, + source, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + /** * Run an ACP `session/prompt` against the underlying SDK session. * @@ -782,10 +1169,9 @@ export class AcpSession { * a synchronous `session.prompt(...)` rejection. Both are * routed through {@link mapPromptError} for parity. * - * Subscribes to the session event stream; for every `assistant.delta`, - * pushes an `agent_message_chunk` `session/update` notification to the - * client. Resolves with the ACP `PromptResponse` (containing - * `stopReason`) when a `turn.ended` event arrives. + * The session-lifetime event bridge streams updates independently of this + * request. This method only waits for the turn's terminal event and resolves + * with the ACP `PromptResponse` containing its `stopReason`. * * Cleanup invariants: * - The event subscription is unsubscribed on EVERY exit path @@ -795,6 +1181,12 @@ export class AcpSession { * sees a JSON-RPC error rather than a hung request. */ async prompt(blocks: readonly ContentBlock[]): Promise { + if (this.disposed) { + throw RequestError.internalError( + { sessionId: this.id }, + 'ACP session transport closed before the prompt turn started', + ); + } // Compression happens before any turn exists, so honor a `session/cancel` // that arrives during it: flip the flag from cancel() and bail out here // rather than launching a turn the client already asked to stop. @@ -823,7 +1215,6 @@ export class AcpSession { return { stopReason: 'cancelled' }; } const sessionId = this.id; - const conn = this.conn; // ACP clients send slash commands as plain text `ContentBlock`s in // `session/prompt`. Intercept only commands the adapter can execute @@ -835,12 +1226,17 @@ export class AcpSession { this.emitTelemetry('acp_skill_activated', { skill_name: intent.skillName }); const skillName = intent.skillName; const skillArgs = intent.args; - return this.runTurnBody(sessionId, conn, () => + const activationId = randomUUID(); + return this.runTurnBody(sessionId, { kind: 'skill_activation', activationId }, () => // `activateSkill` accepts `args?: string | undefined`; pass the // empty string through verbatim — the SDK's // `normalizeOptionalString` converts `''` to `undefined`, which // is the canonical "no args" form for the skill renderer. - this.session.activateSkill(skillName, skillArgs.length > 0 ? skillArgs : undefined), + this.session.activateSkill( + skillName, + skillArgs.length > 0 ? skillArgs : undefined, + { activationId }, + ), ); } if (intent.kind === 'builtin') { @@ -850,7 +1246,10 @@ export class AcpSession { return this.runUnknownSlashCommand(intent.name); } - return this.runTurnBody(sessionId, conn, () => this.session.prompt(parts)); + const promptId = randomUUID(); + return this.runTurnBody(sessionId, { kind: 'user', promptId }, () => + this.session.prompt(parts, { promptId }), + ); } private async runBuiltInCommand( @@ -973,315 +1372,172 @@ export class AcpSession { } /** - * Body of {@link prompt}, extracted so the event-listener invariants - * — single `onEvent` subscription, `settled` flag semantics, - * `currentTurnId` reset — live in one place and can be driven by + * Body of {@link prompt}, extracted so the prompt-completion listener + * invariants live in one place and can be driven by * either `Session.prompt(parts)` or `Session.activateSkill(name, args)`. - * Both entry points trigger the same downstream turn (skill - * activation internally calls `agent.turn.prompt(...)` after - * injecting the `` block — see - * `packages/agent-core/src/agent/skill/index.ts`), so the event - * subscription's `turn.started` / `turn.ended` semantics apply - * uniformly. + * Both entry points carry a caller-generated correlation id that the SDK + * echoes in `turn.started.origin`. A request owns a turn only after that id + * matches, then accepts only the same turn id's terminal event. The admission + * gate permits only one not-yet-owned kick at a time so v1's uncorrelated + * `TURN_AGENT_BUSY` event can reject only the request that caused it. */ private runTurnBody( sessionId: string, - conn: AgentSideConnection, + correlation: PromptCorrelation, kick: () => Promise, ): Promise { return new Promise((resolve, reject) => { - let settled = false; - const isFromMainAgent = (event: { agentId?: string }): boolean => - event.agentId === undefined || event.agentId === MAIN_AGENT_ID; - // Per-tool-call streaming args accumulator. Lives in the Promise - // executor closure so each `prompt()` invocation gets its own - // map and no state leaks across concurrent or sequential turns. - // Keyed on the **SDK** `toolCallId` (not the ACP-prefixed one) - // because the SDK delta events only carry the raw id. - const argsByToolCall = new Map(); - // Set of **wire-level** (turn-prefixed) tool-call ids for which - // we have already sent the `tool_call` CREATE notification. The - // agent-core actually emits `tool.call.delta` events BEFORE - // `tool.call.started` (deltas come from the model's args stream; - // the started event comes from the loop dispatching the call - // afterwards). Without this set, the naive "started → tool_call, - // delta → tool_call_update" mapping puts updates on the wire - // ahead of the create, and clients such as Zed surface "Tool - // call not found" until the create eventually lands. We instead - // lazy-create the wire `tool_call` on the first delta and - // downgrade the eventual started event into a `tool_call_update` - // carrying the canonical title/kind/rawInput (and any - // `display`-derived diff). - // - // Keyed on the wire id (`${turnId}:${rawToolCallId}`) — not the - // raw SDK `toolCallId` — because providers may legitimately - // reuse the same raw id across turns within one prompt, and - // each turn produces a distinct wire-level tool call that needs - // its own CREATE. - const startedToolCalls = new Set(); - const initialActiveTurnId = this.currentTurnId; - let hasReceivedOwnTurnStarted = false; - const unsub = this.session.onEvent((event) => { - if ( - event.type === 'turn.started' && - isFromMainAgent(event) && - (initialActiveTurnId === undefined || event.turnId !== initialActiveTurnId) - ) { - hasReceivedOwnTurnStarted = true; - } - // Track the active turn so `handleApproval` (registered once at - // construction, called via `setApprovalHandler`) can compose the - // prefixed `${turnId}:${toolCallId}` wire id that matches the - // tool card the client already rendered. This branch is purely - // additive: it runs before the existing dispatch and never - // returns, so the if-chain below behaves exactly as in Phase 4. - // Subagent turn events carry their own `turnId`; filtering on - // `agentId` keeps `currentTurnId` aligned with the parent turn - // that the approval prompt actually belongs to. - if ( - 'turnId' in event && - typeof event.turnId === 'number' && - isFromMainAgent(event) - ) { - this.currentTurnId = event.turnId; - } - if (event.type === 'error') { - if (settled) return; - if (!isFromMainAgent(event)) return; - if (event.code !== ErrorCodes.TURN_AGENT_BUSY) return; - if (hasReceivedOwnTurnStarted) return; - settled = true; - argsByToolCall.clear(); - startedToolCalls.clear(); - this.currentTurnId = undefined; - unsub(); - log.warn('acp: prompt rejected because another turn is active', { - sessionId, - details: event.details, - }); - reject( - RequestError.invalidRequest( - { code: event.code, details: event.details }, - event.message, - ), - ); - return; - } - if (event.type === 'assistant.delta') { - if (!isFromMainAgent(event)) return; - // `sessionUpdate` is itself async (it serializes onto the - // ndjson stream). The text deltas form a strictly ordered - // single-producer/single-consumer pipeline, so each await - // would force the next delta to wait for the previous flush. - // Fire-and-forget keeps the stream pumping; we log push - // failures rather than dropping them silently. - conn - .sessionUpdate(assistantDeltaToSessionUpdate(sessionId, event)) - .catch((err) => { - log.warn('acp: failed to push agent_message_chunk', { - sessionId, - error: err instanceof Error ? err.message : String(err), - }); - }); - return; - } - if (event.type === 'thinking.delta') { - if (!isFromMainAgent(event)) return; - conn - .sessionUpdate(thinkingDeltaToSessionUpdate(sessionId, event)) - .catch((err) => { - log.warn('acp: failed to push agent_thought_chunk', { - sessionId, - error: err instanceof Error ? err.message : String(err), - }); - }); - return; - } - if (event.type === 'tool.call.started') { - if (!isFromMainAgent(event)) return; - // Seed the accumulator with the **stringified initial args**. - // The wire-level `tool_call_update` is REPLACE-content (not - // append) so each subsequent delta emits the cumulative args - // string; if we seeded with an empty string the first delta - // would silently drop the initial args from the rendered card. - argsByToolCall.set(event.toolCallId, { args: stringifyArgs(event.args) }); - // Branch on whether a streaming delta already lazy-created - // the wire `tool_call` for this id: - // - YES → we cannot send a second `tool_call` CREATE; emit a - // `tool_call_update` (the "upgrade") so `title`/`kind`/ - // `rawInput`/`display`-derived diff land on the existing - // card and `status` flips to `'in_progress'`. - // - NO → no prior deltas (e.g. provider doesn't stream args); - // take the original path and emit the `tool_call` CREATE. - const startedWireId = acpToolCallId(event.turnId, event.toolCallId); - if (startedToolCalls.has(startedWireId)) { - conn - .sessionUpdate(toolCallStartedUpgradeToSessionUpdate(sessionId, event)) - .catch((err) => { - log.warn('acp: failed to push tool_call_update (start upgrade)', { - sessionId, - toolCallId: event.toolCallId, - error: err instanceof Error ? err.message : String(err), - }); - }); - } else { - startedToolCalls.add(startedWireId); - conn - .sessionUpdate(toolCallStartToSessionUpdate(sessionId, event)) - .catch((err) => { - log.warn('acp: failed to push tool_call', { - sessionId, - toolCallId: event.toolCallId, - error: err instanceof Error ? err.message : String(err), - }); - }); - } - // Phase 9.3: when the tool exposed a structured TodoList - // display, additionally fire a `plan` session_update so ACP - // clients can render the agent's evolving TODO list. Other - // display kinds (diff/file_io/command/…) are already folded - // into the tool_call card; only `todo_list` becomes a plan. - // The emission is fire-and-forget under the same idle-stream - // discipline as the assistant deltas above. - if (event.display) { - const planNote = planFromDisplayBlock(sessionId, event.turnId, event.display); - if (planNote !== null) { - conn.sessionUpdate(planNote).catch((err) => { - log.warn('acp: failed to push plan', { - sessionId, - error: err instanceof Error ? err.message : String(err), - }); - }); - } - } - return; - } - if (event.type === 'tool.call.delta') { - if (!isFromMainAgent(event)) return; - // The agent-core emits these args-stream deltas BEFORE the - // `tool.call.started` event (deltas come from the provider's - // streaming phase; started is dispatched afterwards). If we - // haven't yet sent a `tool_call` CREATE for this id, do so now - // from the delta — Zed otherwise sees a `tool_call_update` - // for an unknown id and surfaces "Tool call not found" until - // the start eventually lands. - const deltaWireId = acpToolCallId(event.turnId, event.toolCallId); - if (!startedToolCalls.has(deltaWireId)) { - const initial = event.argumentsPart ?? ''; - argsByToolCall.set(event.toolCallId, { args: initial }); - startedToolCalls.add(deltaWireId); - conn - .sessionUpdate(toolCallLazyCreateToSessionUpdate(sessionId, event)) - .catch((err) => { - log.warn('acp: failed to push tool_call (lazy create from delta)', { - sessionId, - toolCallId: event.toolCallId, - error: err instanceof Error ? err.message : String(err), - }); - }); - return; - } - // Subsequent delta — accumulate then emit an update with the - // cumulative args text (REPLACE-content semantics). - let acc = argsByToolCall.get(event.toolCallId); - if (!acc) { - acc = { args: '' }; - argsByToolCall.set(event.toolCallId, acc); - } - conn - .sessionUpdate(toolCallDeltaToSessionUpdate(sessionId, event, acc)) - .catch((err) => { - log.warn('acp: failed to push tool_call_update (delta)', { - sessionId, - toolCallId: event.toolCallId, - error: err instanceof Error ? err.message : String(err), - }); - }); - return; - } - if (event.type === 'tool.progress') { - if (!isFromMainAgent(event)) return; - const note = toolProgressToSessionUpdate(sessionId, event); - if (note === null) return; - conn.sessionUpdate(note).catch((err) => { - log.warn('acp: failed to push tool_call_update (progress)', { - sessionId, - toolCallId: event.toolCallId, - error: err instanceof Error ? err.message : String(err), - }); - }); - return; - } - if (event.type === 'tool.result') { - if (!isFromMainAgent(event)) return; - conn - .sessionUpdate(toolResultToSessionUpdate(sessionId, event)) - .catch((err) => { - log.warn('acp: failed to push tool_call_update (result)', { - sessionId, - toolCallId: event.toolCallId, - error: err instanceof Error ? err.message : String(err), - }); - }); - return; - } - if (event.type === 'turn.ended') { - if (settled) return; - if (!isFromMainAgent(event)) return; - settled = true; - if (event.reason === 'failed') { - // Failures bubble up via the SDK `error` payload. Phase 11.1 - // upgrades the prior "log + resolve end_turn" behaviour to - // route auth-coded failures through `RequestError.authRequired()` - // so the client can trigger its re-auth UX. Other failure - // codes still resolve with `end_turn` (the spec discourages - // signaling errors through `stopReason`; the failure is - // observable in the log). - log.warn('acp: turn ended with failed reason', { - sessionId, - error: event.error, - }); - argsByToolCall.clear(); - startedToolCalls.clear(); - this.currentTurnId = undefined; - unsub(); - const authErr = authRequiredFromPayload(event.error); - if (authErr) { - reject(authErr); - return; - } - } else { - if (event.reason === 'blocked') { - // Provider safety and prompt hooks both map to ACP `refusal` - // (see turnEndReasonToStopReason); log them here too so the - // block stays observable in the agent logs, mirroring the - // `failed` branch above. - log.warn('acp: turn ended with blocked reason', { - reason: event.reason, - sessionId, - }); - } - argsByToolCall.clear(); - startedToolCalls.clear(); - // Drop the turnId so a late-arriving approval (e.g. an SDK - // reverse-RPC racing the turn boundary) falls back to the raw - // SDK id rather than re-prefixing with a stale value. - this.currentTurnId = undefined; - unsub(); - } - resolve({ stopReason: turnEndReasonToStopReason(event.reason, event.error) }); - } + if (this.disposed) { + reject( + RequestError.internalError( + { sessionId }, + 'ACP session transport closed before the prompt turn started', + ), + ); + return; + } + const admission: PromptAdmission = { + sessionId, + correlation, + kick, + resolve, + reject, + kickStarted: false, + settled: false, + }; + admission.unsubscribe = this.session.onEvent((event) => { + this.handlePromptAdmissionEvent(admission, event); }); + this.promptAdmissions.push(admission); + this.startNextPromptAdmission(); + }); + } - kick().catch((err) => { - if (settled) return; - settled = true; - unsub(); - reject(mapPromptError(err, sessionId)); + private startNextPromptAdmission(): void { + if (this.disposed || this.admittingPrompt !== undefined) return; + const admission = this.promptAdmissions.find((candidate) => !candidate.kickStarted); + if (admission === undefined) return; + admission.kickStarted = true; + this.admittingPrompt = admission; + let kicked: Promise; + try { + kicked = admission.kick(); + } catch (error) { + this.rejectPromptAdmission(admission, mapPromptError(error, admission.sessionId)); + return; + } + void kicked.catch((error) => { + if (admission.settled) return; + if (admission.turnId !== undefined) { + log.warn('acp: prompt launch rejected after its turn started; waiting for turn end', { + sessionId: admission.sessionId, + turnId: admission.turnId, + error: errorMessage(error), + }); + return; + } + this.rejectPromptAdmission(admission, mapPromptError(error, admission.sessionId)); + }); + } + + private handlePromptAdmissionEvent(admission: PromptAdmission, event: Event): void { + if (admission.settled || !this.isFromMainAgent(event)) return; + if (event.type === 'prompt.completed') { + if ( + this.admittingPrompt !== admission || + admission.turnId !== undefined || + admission.correlation.kind !== 'user' || + event.promptId !== admission.correlation.promptId + ) { + return; + } + if (event.reason === 'failed' || event.reason === 'blocked') { + log.warn('acp: prompt completed before a turn was launched', { + sessionId: admission.sessionId, + reason: event.reason, + }); + } + this.resolvePromptAdmission(admission, { + stopReason: turnEndReasonToStopReason(event.reason ?? 'completed'), + }); + return; + } + if (event.type === 'turn.started') { + if (!admission.kickStarted || !matchesPromptCorrelation(event.origin, admission.correlation)) { + return; + } + admission.turnId = event.turnId; + if (this.admittingPrompt === admission) { + this.admittingPrompt = undefined; + queueMicrotask(() => this.startNextPromptAdmission()); + } + return; + } + if (event.type === 'error') { + if (event.code !== ErrorCodes.TURN_AGENT_BUSY) return; + if (this.admittingPrompt !== admission || admission.turnId !== undefined) return; + log.warn('acp: prompt rejected because another turn is active', { + sessionId: admission.sessionId, + details: event.details, }); + this.rejectPromptAdmission( + admission, + RequestError.invalidRequest( + { code: event.code, details: event.details }, + event.message, + ), + ); + return; + } + if (event.type !== 'turn.ended' || event.turnId !== admission.turnId) return; + if (event.reason === 'failed') { + log.warn('acp: turn ended with failed reason', { + sessionId: admission.sessionId, + error: event.error, + }); + const authErr = authRequiredFromPayload(event.error); + if (authErr) { + this.rejectPromptAdmission(admission, authErr); + return; + } + } else if (event.reason === 'blocked') { + log.warn('acp: turn ended with blocked reason', { + reason: event.reason, + sessionId: admission.sessionId, + }); + } + this.resolvePromptAdmission(admission, { + stopReason: turnEndReasonToStopReason(event.reason, event.error), }); } + private resolvePromptAdmission( + admission: PromptAdmission, + response: PromptResponse, + ): void { + if (!this.finishPromptAdmission(admission)) return; + admission.resolve(response); + } + + private rejectPromptAdmission(admission: PromptAdmission, error: unknown): void { + if (!this.finishPromptAdmission(admission)) return; + admission.reject(error); + } + + private finishPromptAdmission(admission: PromptAdmission): boolean { + if (admission.settled) return false; + admission.settled = true; + admission.unsubscribe?.(); + admission.unsubscribe = undefined; + const index = this.promptAdmissions.indexOf(admission); + if (index >= 0) this.promptAdmissions.splice(index, 1); + if (this.admittingPrompt === admission) { + this.admittingPrompt = undefined; + queueMicrotask(() => this.startNextPromptAdmission()); + } + return true; + } + /** * Bridge an SDK {@link ApprovalRequest} through the ACP reverse-RPC * `session/request_permission`. @@ -1657,6 +1913,54 @@ function authRequiredFromUnknown(err: unknown): RequestError | undefined { return undefined; } +function matchesPromptCorrelation( + origin: Extract['origin'], + correlation: PromptCorrelation, +): boolean { + if (correlation.kind === 'user') { + return origin.kind === 'user' && origin.promptId === correlation.promptId; + } + return ( + origin.kind === 'skill_activation' && + origin.activationId === correlation.activationId + ); +} + +/** + * Build the user-visible portion of a background-task completion without + * exposing the model-facing `` XML or its output-control + * instructions. The task description is already public lifecycle metadata + * used by client task lists; stop reasons and output are deliberately omitted. + */ +function taskCompletionDisplayText( + info: TaskTerminationEvent['info'], +): string | undefined { + if ( + info.status === 'running' || + info.detached === false || + info.terminalNotificationSuppressed === true + ) { + return undefined; + } + const description = info.description.trim(); + const subject = + description.length > 0 + ? description + : `Background ${info.kind} task`; + switch (info.status) { + case 'completed': + return `${subject} completed.`; + case 'failed': + return `${subject} failed.`; + case 'timed_out': + return `${subject} timed out.`; + case 'killed': + return `${subject} was stopped.`; + case 'lost': + return `${subject} was lost.`; + } +} + /** * Identifier the agent-core session emits for the main (user-facing) * agent. Subagents are issued generated ids by `Session.spawnAgent`; diff --git a/packages/acp-adapter/test/_helpers/real-engine-rig.ts b/packages/acp-adapter/test/_helpers/real-engine-rig.ts new file mode 100644 index 0000000000..b5417f2a8f --- /dev/null +++ b/packages/acp-adapter/test/_helpers/real-engine-rig.ts @@ -0,0 +1,420 @@ +import { once } from 'node:events'; +import { readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { + ClientSideConnection, + ndJsonStream, + type Client, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import { + createKimiHarness, + createKimiHarnessV2, + type Event, + type KimiHarness, + type Session, +} from '@moonshot-ai/kimi-code-sdk'; + +import { runAcpServerWithStream } from '../../src/server'; + +const TEST_IDENTITY = { + userAgentProduct: 'kimi-code-cli', + version: '0.0.0-test', +} as const; +const API_KEY = 'YOUR_API_KEY'; +const MODEL = 'stub-model'; +const WAIT_TIMEOUT_MS = 15_000; + +export type Engine = 'v1' | 'v2'; + +export type ModelReply = + | { readonly kind: 'text'; readonly text: string } + | { + readonly kind: 'tool'; + readonly id: string; + readonly name: string; + readonly arguments: Readonly>; + }; + +export interface ModelRequest { + readonly authorization: string | undefined; + readonly body: Readonly>; +} + +class LoopbackModelServer { + readonly requests: ModelRequest[] = []; + readonly baseUrl: string; + + private constructor( + private readonly server: Server, + private readonly replies: readonly ModelReply[], + port: number, + ) { + this.baseUrl = `http://127.0.0.1:${String(port)}/v1`; + } + + static async start(replies: readonly ModelReply[]): Promise { + let fixture: LoopbackModelServer | undefined; + const server = createServer((request, response) => { + void fixture?.handle(request, response); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address() as AddressInfo; + fixture = new LoopbackModelServer(server, replies, address.port); + return fixture; + } + + async close(): Promise { + const closed = once(this.server, 'close'); + this.server.closeAllConnections(); + this.server.close(); + await closed; + } + + private async handle(request: IncomingMessage, response: ServerResponse): Promise { + if (request.method === 'GET' && request.url === '/v1/models') { + respondJson(response, 200, { + object: 'list', + data: [{ id: MODEL, object: 'model', owned_by: 'example' }], + }); + return; + } + if (request.method !== 'POST' || request.url !== '/v1/chat/completions') { + request.resume(); + respondJson(response, 404, { error: { message: 'unknown test endpoint' } }); + return; + } + + const authorization = headerValue(request.headers.authorization); + if (authorization !== `Bearer ${API_KEY}`) { + request.resume(); + respondJson(response, 401, { error: { message: 'invalid test authorization' } }); + return; + } + + try { + const body = await readJsonBody(request); + const reply = this.replies[this.requests.length]; + this.requests.push({ authorization, body }); + if (reply === undefined) { + respondJson(response, 500, { error: { message: 'unexpected model request' } }); + return; + } + respondSse(response, reply, this.requests.length); + } catch (error) { + respondJson(response, 400, { + error: { message: error instanceof Error ? error.message : String(error) }, + }); + } + } +} + +export class CollectingClient implements Client { + readonly updates: SessionNotification[] = []; + private readonly waiters = new Set<{ + readonly predicate: (notification: SessionNotification) => boolean; + readonly resolve: (notification: SessionNotification) => void; + readonly reject: (error: Error) => void; + readonly signal: AbortSignal; + readonly onAbort: () => void; + }>(); + + async requestPermission(_request: RequestPermissionRequest): Promise { + throw new Error('requestPermission should not be called in the real-engine ACP rig'); + } + + async sessionUpdate(notification: SessionNotification): Promise { + this.updates.push(notification); + for (const waiter of this.waiters) { + if (!waiter.predicate(notification)) continue; + this.waiters.delete(waiter); + waiter.signal.removeEventListener('abort', waiter.onAbort); + waiter.resolve(notification); + } + } + + async writeTextFile(request: WriteTextFileRequest): Promise { + await writeFile(request.path, request.content, 'utf-8'); + return {}; + } + + async readTextFile(request: ReadTextFileRequest): Promise { + return { content: await readFile(request.path, 'utf-8') }; + } + + waitForUpdate( + predicate: (notification: SessionNotification) => boolean, + label: string, + ): Promise { + const existing = this.updates.find(predicate); + if (existing !== undefined) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + const signal = AbortSignal.timeout(WAIT_TIMEOUT_MS); + const waiter = { + predicate, + resolve, + reject, + signal, + onAbort: () => { + this.waiters.delete(waiter); + reject( + new Error( + `Timed out waiting for ACP update "${label}". Updates: ${JSON.stringify(this.updates)}`, + ), + ); + }, + }; + signal.addEventListener('abort', waiter.onAbort, { once: true }); + this.waiters.add(waiter); + }); + } +} + +export interface RealEngineRig { + readonly client: ClientSideConnection; + readonly collecting: CollectingClient; + readonly harness: KimiHarness; + readonly modelRequests: readonly ModelRequest[]; + readonly session: Session; + readonly workDir: string; + close(): Promise; +} + +export async function createRealEngineRig(options: { + readonly engine: Engine; + readonly homeDir: string; + readonly workDir: string; + readonly replies: readonly ModelReply[]; + readonly additionalConfig?: string; +}): Promise { + const modelServer = await LoopbackModelServer.start(options.replies); + let harness: KimiHarness | undefined; + let clientToAgent: TransformStream | undefined; + let agentToClient: TransformStream | undefined; + let client: ClientSideConnection | undefined; + let serverRun: Promise | undefined; + const cleanup = () => + runCleanupSteps([ + () => clientToAgent?.writable.close(), + () => serverRun, + () => agentToClient?.writable.close(), + () => client?.closed, + () => harness?.close(), + () => modelServer.close(), + () => rm(options.homeDir, { recursive: true, force: true }), + () => rm(options.workDir, { recursive: true, force: true }), + ]); + try { + await writeFile( + `${options.homeDir}/config.toml`, + modelConfig(modelServer.baseUrl, options.additionalConfig), + 'utf-8', + ); + const harnessFactory = options.engine === 'v1' ? createKimiHarness : createKimiHarnessV2; + harness = harnessFactory({ homeDir: options.homeDir, identity: TEST_IDENTITY }); + + clientToAgent = new TransformStream(); + agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + serverRun = runAcpServerWithStream(harness, agentStream); + const collecting = new CollectingClient(); + client = new ClientSideConnection(() => collecting, clientStream); + const response = await client.newSession({ cwd: options.workDir, mcpServers: [] }); + const session = harness.getSession(response.sessionId); + if (session === undefined) { + throw new Error(`Harness did not retain ACP session ${response.sessionId}`); + } + + let closePromise: Promise | undefined; + return { + client, + collecting, + harness, + modelRequests: modelServer.requests, + session, + workDir: options.workDir, + close() { + closePromise ??= cleanup(); + return closePromise; + }, + }; + } catch (error) { + await cleanup().catch(() => undefined); + throw error; + } +} + +async function runCleanupSteps( + steps: readonly (() => Promise | undefined)[], +): Promise { + const errors: unknown[] = []; + for (const step of steps) { + try { + await step(); + } catch (error) { + errors.push(error); + } + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError(errors, 'Failed to close the real-engine ACP test rig'); + } +} + +export function waitForSessionEvent( + session: Session, + predicate: (event: Event) => boolean, + label: string, +): Promise { + const seen: Event[] = []; + return new Promise((resolve, reject) => { + const signal = AbortSignal.timeout(WAIT_TIMEOUT_MS); + const unsubscribe = session.onEvent((event) => { + seen.push(event); + if (!predicate(event)) return; + signal.removeEventListener('abort', onAbort); + unsubscribe(); + resolve(event); + }); + const onAbort = () => { + unsubscribe(); + reject( + new Error(`Timed out waiting for SDK event "${label}". Events: ${JSON.stringify(seen)}`), + ); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +function modelConfig(baseUrl: string, additionalConfig: string | undefined): string { + return ` +default_provider = "local" +default_model = "${MODEL}" +default_permission_mode = "yolo" +telemetry = false + +[providers.local] +type = "kimi" +api_key = "${API_KEY}" +base_url = "${baseUrl}" + +[models.${MODEL}] +provider = "local" +model = "${MODEL}" +max_context_size = 262144 +${additionalConfig ?? ''} +`; +} + +async function readJsonBody( + request: IncomingMessage, +): Promise>> { + let body = ''; + for await (const chunk of request) { + body += chunk.toString(); + } + const parsed: unknown = JSON.parse(body); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('model request body must be an object'); + } + return parsed as Readonly>; +} + +function respondSse(response: ServerResponse, reply: ModelReply, sequence: number): void { + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }); + const base = { + id: `stub-completion-${String(sequence)}`, + object: 'chat.completion.chunk', + created: 1, + model: MODEL, + }; + if (reply.kind === 'tool') { + writeSse(response, { + ...base, + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: reply.id, + type: 'function', + function: { name: reply.name, arguments: '' }, + }, + ], + }, + finish_reason: null, + }, + ], + }); + writeSse(response, { + ...base, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: JSON.stringify(reply.arguments) }, + }, + ], + }, + finish_reason: null, + }, + ], + }); + writeSse(response, { + ...base, + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }); + } else { + writeSse(response, { + ...base, + choices: [ + { + index: 0, + delta: { role: 'assistant', content: reply.text }, + finish_reason: null, + }, + ], + }); + writeSse(response, { + ...base, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }); + } + response.end('data: [DONE]\n\n'); +} + +function writeSse(response: ServerResponse, value: unknown): void { + response.write(`data: ${JSON.stringify(value)}\n\n`); +} + +function respondJson(response: ServerResponse, status: number, value: unknown): void { + if (response.headersSent) return; + response.writeHead(status, { 'content-type': 'application/json' }); + response.end(JSON.stringify(value)); +} + +function headerValue(value: string | readonly string[] | undefined): string | undefined { + return typeof value === 'string' ? value : value?.[0]; +} diff --git a/packages/acp-adapter/test/agent-initiated-engine.e2e.test.ts b/packages/acp-adapter/test/agent-initiated-engine.e2e.test.ts new file mode 100644 index 0000000000..32e7d34e37 --- /dev/null +++ b/packages/acp-adapter/test/agent-initiated-engine.e2e.test.ts @@ -0,0 +1,201 @@ +/** + * Scenario: an idle engine launches work independently of any ACP prompt request. + * Responsibilities: project the safe trigger, tool lifecycle, and final reply over ACP NDJSON. + * Wiring: real v1/v2 harnesses, engines, node SDK, ACP connections, filesystem, and shell task; + * only the remote Chat Completions endpoint is stubbed on loopback. + * Run: pnpm --filter @moonshot-ai/acp-adapter exec vitest run test/agent-initiated-engine.e2e.test.ts + */ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + createRealEngineRig, + type Engine, + type RealEngineRig, + waitForSessionEvent, +} from './_helpers/real-engine-rig'; + +const LONG_RUNNING_COMMAND = "node -e 'setInterval(()=>{},1e3)'"; +const SAFE_TERMINATION_TEXT = 'Test background task was stopped.'; + +const rigs: RealEngineRig[] = []; + +afterEach(async () => { + for (const rig of rigs.splice(0).toReversed()) { + await rig.close(); + } +}); + +describe('ACP idle engine turn projection', () => { + it.each(['v1', 'v2'] as const)( + 'projects a complete idle task-notification turn through the %s engine', + async (engine) => { + const rig = await createAutonomousRig(engine); + await expect( + rig.client.prompt({ + sessionId: rig.session.id, + prompt: [{ type: 'text', text: 'Start the test background task.' }], + }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + const activeTasks = await rig.session.listBackgroundTasks({ activeOnly: true }); + expect(activeTasks).toEqual([ + expect.objectContaining({ + kind: 'process', + command: LONG_RUNNING_COMMAND, + detached: true, + status: 'running', + }), + ]); + const task = activeTasks[0]; + if (task === undefined) { + throw new Error(`${engine} did not retain the background task`); + } + rig.collecting.updates.length = 0; + + const terminated = waitForSessionEvent( + rig.session, + (event) => + event.type === 'background.task.terminated' && + event.info.taskId === task.taskId, + `${engine} background.task.terminated`, + ); + const autonomousTurn = waitForSessionEvent( + rig.session, + (event) => + event.type === 'turn.started' && + event.origin.kind === (engine === 'v1' ? 'background_task' : 'task') && + event.origin.taskId === task.taskId, + `${engine} background turn.started`, + ); + const turnEnded = waitForSessionEvent( + rig.session, + (event) => event.type === 'turn.ended', + `${engine} turn.ended`, + ); + const safeTrigger = rig.collecting.waitForUpdate( + (notification) => + notification.update.sessionUpdate === 'user_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === SAFE_TERMINATION_TEXT, + `${engine} safe autonomous trigger`, + ); + const toolStarted = rig.collecting.waitForUpdate( + (notification) => notification.update.sessionUpdate === 'tool_call', + `${engine} tool_call`, + ); + const assistantReply = rig.collecting.waitForUpdate( + (notification) => + notification.update.sessionUpdate === 'agent_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === 'Autonomous review finished.', + `${engine} assistant reply`, + ); + + await rig.session.stopBackgroundTask(task.taskId); + const [ + terminatedEvent, + autonomousEvent, + endedEvent, + triggerUpdate, + toolUpdate, + replyUpdate, + ] = await Promise.all([ + terminated, + autonomousTurn, + turnEnded, + safeTrigger, + toolStarted, + assistantReply, + ]); + const toolCallId = toolUpdate.update.sessionUpdate === 'tool_call' + ? toolUpdate.update.toolCallId + : undefined; + if (toolCallId === undefined) { + throw new Error('tool_call waiter returned a different ACP update'); + } + const toolCompleted = await rig.collecting.waitForUpdate( + (notification) => + notification.update.sessionUpdate === 'tool_call_update' && + notification.update.toolCallId === toolCallId && + notification.update.status === 'completed', + `${engine} completed tool_call_update for ${toolCallId}`, + ); + expect(terminatedEvent.type).toBe('background.task.terminated'); + expect(autonomousEvent.type).toBe('turn.started'); + expect(endedEvent).toMatchObject({ type: 'turn.ended', reason: 'completed' }); + expect(rig.modelRequests).toHaveLength(4); + expect(rig.modelRequests.map((request) => request.authorization)).toEqual([ + 'Bearer YOUR_API_KEY', + 'Bearer YOUR_API_KEY', + 'Bearer YOUR_API_KEY', + 'Bearer YOUR_API_KEY', + ]); + expect(rig.modelRequests[2]?.body).toMatchObject({ + model: 'stub-model', + stream: true, + tools: expect.arrayContaining([ + expect.objectContaining({ + function: expect.objectContaining({ name: 'Read' }), + }), + ]), + }); + expect(rig.modelRequests[3]?.body).toMatchObject({ + messages: expect.arrayContaining([ + expect.objectContaining({ role: 'tool', content: expect.stringContaining('fixture contents') }), + ]), + }); + + const orderedUpdates = [ + triggerUpdate, + toolUpdate, + toolCompleted, + replyUpdate, + ].map((update) => rig.collecting.updates.indexOf(update)); + expect(orderedUpdates).toEqual(orderedUpdates.toSorted((left, right) => left - right)); + expect(new Set(orderedUpdates).size).toBe(orderedUpdates.length); + + const wire = JSON.stringify(rig.collecting.updates); + expect(wire).not.toContain(' { + const homeDir = await mkdtemp(join(tmpdir(), `kimi-acp-${engine}-home-`)); + const workDir = await mkdtemp(join(tmpdir(), `kimi-acp-${engine}-work-`)); + const readPath = join(workDir, 'fixture.txt'); + await writeFile(readPath, 'fixture contents', 'utf-8'); + const rig = await createRealEngineRig({ + engine, + homeDir, + workDir, + replies: [ + { + kind: 'tool', + id: 'call_start_background', + name: 'Bash', + arguments: { + command: LONG_RUNNING_COMMAND, + description: 'Test background task', + run_in_background: true, + }, + }, + { kind: 'text', text: 'Background task started.' }, + { + kind: 'tool', + id: 'call_read_fixture', + name: 'Read', + arguments: { path: readPath }, + }, + { kind: 'text', text: 'Autonomous review finished.' }, + ], + }); + rigs.push(rig); + return rig; +} diff --git a/packages/acp-adapter/test/approval-cancel.test.ts b/packages/acp-adapter/test/approval-cancel.test.ts index c491104be1..54f88b1acb 100644 --- a/packages/acp-adapter/test/approval-cancel.test.ts +++ b/packages/acp-adapter/test/approval-cancel.test.ts @@ -77,10 +77,12 @@ function makeInMemoryStreamPair(): { * `session/cancel` notification reached the SDK while another request * was parked. */ -function makeCancellableApprovalSession(sessionId: string): { +function makeCancellableApprovalSession(sessionId: string, turnId: number): { session: Session; emit: (event: Event) => void; invokeHandler: (req: ApprovalRequest) => Promise | ApprovalResponse; + promptStarted: Promise; + cancelled: Promise; resolvePrompt: () => void; cancelCalls: () => number; } { @@ -88,16 +90,42 @@ function makeCancellableApprovalSession(sessionId: string): { let approvalHandler: ApprovalHandler | undefined; let releasePrompt: (() => void) | undefined; let cancelCount = 0; + let signalPromptStarted!: () => void; + const promptStarted = new Promise((resolve) => { + signalPromptStarted = resolve; + }); + let signalCancelled!: () => void; + const cancelled = new Promise((resolve) => { + signalCancelled = resolve; + }); const session = { id: sessionId, - prompt: async (_input: unknown) => { + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { + const promptId = options?.promptId; + if (promptId === undefined) { + throw new Error('AcpSession did not correlate the SDK prompt'); + } + for (const fn of listeners) { + fn({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId, + origin: { kind: 'user', promptId }, + } as Event); + } + signalPromptStarted(); await new Promise((resolve) => { releasePrompt = resolve; }); }, cancel: async () => { cancelCount += 1; + signalCancelled(); }, onEvent: (fn: (event: Event) => void) => { listeners.add(fn); @@ -121,6 +149,8 @@ function makeCancellableApprovalSession(sessionId: string): { } return approvalHandler(req); }, + promptStarted, + cancelled, resolvePrompt: () => releasePrompt?.(), cancelCalls: () => cancelCount, }; @@ -186,7 +216,7 @@ describe('AcpServer cancel ⇄ pending requestPermission', () => { it('processes session/cancel without blocking on an in-flight requestPermission, and the parked request can still settle to { decision: cancelled }', async () => { const sessionId = 'sess-cancel-while-approval'; const turnId = 11; - const handle = makeCancellableApprovalSession(sessionId); + const handle = makeCancellableApprovalSession(sessionId, turnId); const harness = { auth: { status: async () => AUTHED_STATUS }, createSession: async () => handle.session, @@ -206,8 +236,7 @@ describe('AcpServer cancel ⇄ pending requestPermission', () => { prompt: [textBlock('do the thing')], }); - // Yield once so the agent-side subscribes to events before we emit. - await new Promise((r) => setTimeout(r, 5)); + await handle.promptStarted; // Advance the turnId so `buildPermissionToolCallUpdate` uses the // prefixed `${turnId}:${rawId}` form — proves the cancel test also @@ -246,8 +275,7 @@ describe('AcpServer cancel ⇄ pending requestPermission', () => { // blocked on the pending request, `Session.cancel()` would never // fire and this would hang / fail. await clientConn.cancel({ sessionId }); - // Give the agent a tick to dispatch the notification. - await new Promise((r) => setTimeout(r, 10)); + await handle.cancelled; expect(handle.cancelCalls()).toBe(1); // Now the client honours the cancel by closing the permission @@ -281,7 +309,8 @@ describe('AcpServer cancel ⇄ pending requestPermission', () => { // the approval outcome — and we want a regression that fails if // that changes silently. const sessionId = 'sess-cancel-independent-approval'; - const handle = makeCancellableApprovalSession(sessionId); + const turnId = 1; + const handle = makeCancellableApprovalSession(sessionId, turnId); const harness = { auth: { status: async () => AUTHED_STATUS }, createSession: async () => handle.session, @@ -297,13 +326,13 @@ describe('AcpServer cancel ⇄ pending requestPermission', () => { sessionId, prompt: [textBlock('hi')], }); - await new Promise((r) => setTimeout(r, 5)); + await handle.promptStarted; handle.emit({ type: 'tool.call.started', sessionId, agentId: 'main', - turnId: 1, + turnId, toolCallId: 'tc-ind', name: 'Bash', args: { command: 'echo hi' }, @@ -320,7 +349,7 @@ describe('AcpServer cancel ⇄ pending requestPermission', () => { await client.received; await clientConn.cancel({ sessionId }); - await new Promise((r) => setTimeout(r, 10)); + await handle.cancelled; expect(handle.cancelCalls()).toBe(1); // Client decides to approve anyway. The bridge does not unilaterally @@ -335,7 +364,7 @@ describe('AcpServer cancel ⇄ pending requestPermission', () => { type: 'turn.ended', sessionId, agentId: 'main', - turnId: 1, + turnId, reason: 'cancelled', } as Event); handle.resolvePrompt(); diff --git a/packages/acp-adapter/test/approval-display.test.ts b/packages/acp-adapter/test/approval-display.test.ts index bbedb07e5e..ab9d1db495 100644 --- a/packages/acp-adapter/test/approval-display.test.ts +++ b/packages/acp-adapter/test/approval-display.test.ts @@ -46,19 +46,41 @@ function makeInMemoryStreamPair(): { return { agentStream, clientStream }; } -function makeApprovalSession(sessionId: string): { +function makeApprovalSession(sessionId: string, turnId: number): { session: Session; emit: (event: Event) => void; invokeHandler: (req: ApprovalRequest) => Promise | ApprovalResponse; + promptStarted: Promise; resolvePrompt: () => void; } { const listeners = new Set<(event: Event) => void>(); let approvalHandler: ApprovalHandler | undefined; let releasePrompt: (() => void) | undefined; + let signalPromptStarted!: () => void; + const promptStarted = new Promise((resolve) => { + signalPromptStarted = resolve; + }); const session = { id: sessionId, - prompt: async (_input: unknown) => { + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { + const promptId = options?.promptId; + if (promptId === undefined) { + throw new Error('AcpSession did not correlate the SDK prompt'); + } + for (const fn of listeners) { + fn({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId, + origin: { kind: 'user', promptId }, + } as Event); + } + signalPromptStarted(); await new Promise((resolve) => { releasePrompt = resolve; }); @@ -86,6 +108,7 @@ function makeApprovalSession(sessionId: string): { } return approvalHandler(req); }, + promptStarted, resolvePrompt: () => releasePrompt?.(), }; } @@ -285,7 +308,7 @@ describe('AcpSession ↔ requestPermission bridge (selectedLabel end-to-end)', ( it('attaches the matched option name as ApprovalResponse.selectedLabel and forwards a diff entry in toolCall.content', async () => { const sessionId = 'sess-approval-display'; const turnId = 11; - const handle = makeApprovalSession(sessionId); + const handle = makeApprovalSession(sessionId, turnId); const harness = { auth: { status: async () => AUTHED_STATUS }, createSession: async () => handle.session, @@ -305,8 +328,7 @@ describe('AcpSession ↔ requestPermission bridge (selectedLabel end-to-end)', ( sessionId, prompt: [textBlock('approve me')], }); - // Let the agent-side subscribe before we emit events. - await new Promise((r) => setTimeout(r, 5)); + await handle.promptStarted; handle.emit({ type: 'tool.call.started', diff --git a/packages/acp-adapter/test/approval.test.ts b/packages/acp-adapter/test/approval.test.ts index 19c7bd6be3..e1bb6a6e53 100644 --- a/packages/acp-adapter/test/approval.test.ts +++ b/packages/acp-adapter/test/approval.test.ts @@ -52,22 +52,41 @@ function makeInMemoryStreamPair(): { * Mirrors the pattern from `session-prompt.test.ts` but exposes the * captured handler so the test can drive the reverse-RPC end-to-end. */ -function makeApprovalSession(sessionId: string): { +function makeApprovalSession(sessionId: string, turnId: number): { session: Session; emit: (event: Event) => void; invokeHandler: (req: ApprovalRequest) => Promise | ApprovalResponse; - promptStarted: () => boolean; + promptStarted: Promise; resolvePrompt: () => void; } { const listeners = new Set<(event: Event) => void>(); let approvalHandler: ApprovalHandler | undefined; - let started = false; let releasePrompt: (() => void) | undefined; + let signalPromptStarted!: () => void; + const promptStarted = new Promise((resolve) => { + signalPromptStarted = resolve; + }); const session = { id: sessionId, - prompt: async (_input: unknown) => { - started = true; + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { + const promptId = options?.promptId; + if (promptId === undefined) { + throw new Error('AcpSession did not correlate the SDK prompt'); + } + for (const fn of listeners) { + fn({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId, + origin: { kind: 'user', promptId }, + } as Event); + } + signalPromptStarted(); // Park the prompt so the test can drive events and invoke the // approval handler before the turn settles. The test resolves // this promise explicitly via `resolvePrompt`. @@ -98,7 +117,7 @@ function makeApprovalSession(sessionId: string): { } return approvalHandler(req); }, - promptStarted: () => started, + promptStarted, resolvePrompt: () => releasePrompt?.(), }; } @@ -231,7 +250,7 @@ describe('AcpSession ↔ requestPermission bridge (end-to-end via wire)', () => it('emits a request_permission with options length 3 and prefixed toolCallId when the SDK invokes the registered handler, and resolves it to { decision: approved }', async () => { const sessionId = 'sess-approval-wire'; const turnId = 7; - const handle = makeApprovalSession(sessionId); + const handle = makeApprovalSession(sessionId, turnId); const harness = { auth: { status: async () => AUTHED_STATUS }, createSession: async () => handle.session, @@ -257,11 +276,9 @@ describe('AcpSession ↔ requestPermission bridge (end-to-end via wire)', () => prompt: [textBlock('hi')], }); - // Wait one tick for prompt() to subscribe via onEvent. - await new Promise((r) => setTimeout(r, 5)); + await handle.promptStarted; - // Fire a tool-call-started event so the adapter learns the - // current turnId (any event with `turnId` advances it). + // Fire a tool-call-started event after the correlated turn has begun. handle.emit({ type: 'tool.call.started', sessionId, @@ -313,7 +330,8 @@ describe('AcpSession ↔ requestPermission bridge (end-to-end via wire)', () => it('returns { decision: rejected } when the client throws', async () => { const sessionId = 'sess-approval-fail'; - const handle = makeApprovalSession(sessionId); + const turnId = 1; + const handle = makeApprovalSession(sessionId, turnId); const harness = { auth: { status: async () => AUTHED_STATUS }, createSession: async () => handle.session, @@ -333,7 +351,7 @@ describe('AcpSession ↔ requestPermission bridge (end-to-end via wire)', () => sessionId, prompt: [textBlock('x')], }); - await new Promise((r) => setTimeout(r, 5)); + await handle.promptStarted; const decision = await handle.invokeHandler({ toolCallId: 'tc-x', @@ -347,7 +365,7 @@ describe('AcpSession ↔ requestPermission bridge (end-to-end via wire)', () => type: 'turn.ended', sessionId, agentId: 'main', - turnId: 1, + turnId, reason: 'completed', } as Event); handle.resolvePrompt(); diff --git a/packages/acp-adapter/test/e2e-fs.test.ts b/packages/acp-adapter/test/e2e-fs.test.ts index f2ef77c111..6b38559de8 100644 --- a/packages/acp-adapter/test/e2e-fs.test.ts +++ b/packages/acp-adapter/test/e2e-fs.test.ts @@ -57,6 +57,14 @@ class UnsavedBufferClient implements Client { readonly readRequests: ReadTextFileRequest[] = []; readonly updates: SessionNotification[] = []; unsavedContent = 'UNSAVED BUFFER CONTENT'; + readonly agentMessageReceived: Promise; + private signalAgentMessageReceived: (() => void) | undefined; + + constructor() { + this.agentMessageReceived = new Promise((resolve) => { + this.signalAgentMessageReceived = resolve; + }); + } async readTextFile(p: ReadTextFileRequest): Promise { this.readRequests.push(p); @@ -67,6 +75,10 @@ class UnsavedBufferClient implements Client { } async sessionUpdate(n: SessionNotification): Promise { this.updates.push(n); + if (n.update.sessionUpdate === 'agent_message_chunk') { + this.signalAgentMessageReceived?.(); + this.signalAgentMessageReceived = undefined; + } } async requestPermission(_p: RequestPermissionRequest): Promise { throw new Error('requestPermission not exercised in this e2e test'); @@ -87,10 +99,26 @@ function makeReadingSession( const listeners = new Set<(event: Event) => void>(); return { id: sessionId, - prompt: async (_input: unknown) => { + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { if (kaos === undefined) { throw new Error('kaos missing — boundary injection failed'); } + const promptId = options?.promptId; + if (promptId === undefined) { + throw new Error('AcpSession did not correlate the SDK prompt'); + } + for (const fn of listeners) { + fn({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 1, + origin: { kind: 'user', promptId }, + } as Event); + } const content = await kaos.readText(targetPath); for (const fn of listeners) { @@ -177,9 +205,7 @@ describe('end-to-end FS reverse-RPC', () => { path: expectedWirePath, }); - // Give the agent a tick to flush the queued sessionUpdate write - // through the ndjson stream. - await new Promise((resolve) => setTimeout(resolve, 20)); + await bufferClient.agentMessageReceived; const chunkUpdate = bufferClient.updates.find( (u) => u.update.sessionUpdate === 'agent_message_chunk', @@ -203,7 +229,23 @@ describe('end-to-end FS reverse-RPC', () => { capturedSessionId = options.id ?? 'fallback'; return { id: capturedSessionId, - prompt: async () => { + prompt: async ( + _input: unknown, + promptOptions?: { readonly promptId?: string }, + ) => { + const promptId = promptOptions?.promptId; + if (promptId === undefined) { + throw new Error('AcpSession did not correlate the SDK prompt'); + } + for (const fn of listeners) { + fn({ + type: 'turn.started', + sessionId: capturedSessionId, + agentId: 'main', + turnId: 1, + origin: { kind: 'user', promptId }, + } as Event); + } for (const fn of listeners) { fn({ type: 'turn.ended', diff --git a/packages/acp-adapter/test/e2e-happy-path.test.ts b/packages/acp-adapter/test/e2e-happy-path.test.ts index 8ee7c56da8..2f4e3fa301 100644 --- a/packages/acp-adapter/test/e2e-happy-path.test.ts +++ b/packages/acp-adapter/test/e2e-happy-path.test.ts @@ -17,6 +17,8 @@ * update and resolves with `stopReason: 'end_turn'`. * 4. `session/cancel` mid-stream resolves the prompt with * `stopReason: 'cancelled'` and does not throw. + * 5. A main-agent turn started by the runtime while no ACP prompt is + * in flight still streams its `session/update` notifications. * * The `promptUpdates` getter filters out the `available_commands_update` * one-shot that `newSession` emits (Phase 9), matching the pattern @@ -39,13 +41,28 @@ import { type WriteTextFileRequest, type WriteTextFileResponse, } from '@agentclientprotocol/sdk'; -import type { Event, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { + ApprovalHandler, + ApprovalRequest, + ApprovalResponse, + Event, + KimiHarness, + QuestionAnswers, + QuestionHandler, + QuestionRequest, + QuestionResult, + Session, +} from '@moonshot-ai/kimi-code-sdk'; -import { AcpServer } from '../src/server'; +import { AcpServer, runAcpServerWithStream } from '../src/server'; import { AUTHED_STATUS, makeModelsMap } from './_helpers/harness-stubs'; class CollectingClient implements Client { readonly updates: SessionNotification[] = []; + private readonly updateWaiters = new Set<{ + readonly predicate: (notification: SessionNotification) => boolean; + readonly resolve: (notification: SessionNotification) => void; + }>(); /** * Filters out the `available_commands_update` one-shot that @@ -65,6 +82,11 @@ class CollectingClient implements Client { } async sessionUpdate(n: SessionNotification): Promise { this.updates.push(n); + for (const waiter of this.updateWaiters) { + if (!waiter.predicate(n)) continue; + this.updateWaiters.delete(waiter); + waiter.resolve(n); + } } async writeTextFile(_p: WriteTextFileRequest): Promise { throw new Error('CollectingClient.writeTextFile should not be called in happy-path test'); @@ -72,42 +94,118 @@ class CollectingClient implements Client { async readTextFile(_p: ReadTextFileRequest): Promise { throw new Error('CollectingClient.readTextFile should not be called in happy-path test'); } + + waitForUpdate( + predicate: (notification: SessionNotification) => boolean, + ): Promise { + const existing = this.updates.find(predicate); + if (existing !== undefined) return Promise.resolve(existing); + return new Promise((resolve) => { + this.updateWaiters.add({ predicate, resolve }); + }); + } +} + +class InteractionClient extends CollectingClient { + readonly permissionRequests: RequestPermissionRequest[] = []; + + override async requestPermission( + request: RequestPermissionRequest, + ): Promise { + this.permissionRequests.push(request); + const option = request.options[0]; + if (option === undefined) { + return { outcome: { outcome: 'cancelled' } }; + } + return { + outcome: { outcome: 'selected', optionId: option.optionId }, + }; + } } function makeInMemoryStreamPair(): { agentStream: ReturnType; clientStream: ReturnType; + closeAgentInput: () => Promise; + closeClientInput: () => Promise; } { const clientToAgent = new TransformStream(); const agentToClient = new TransformStream(); const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); - return { agentStream, clientStream }; + return { + agentStream, + clientStream, + closeAgentInput: () => clientToAgent.writable.close(), + closeClientInput: () => agentToClient.writable.close(), + }; } /** * Build a scripted Session whose `prompt()` synchronously emits a * pre-recorded sequence of `Event`s through any subscribed listener. - * `onEvent` tracks listener registrations so the test can assert - * the AcpSession unsubscribes after `turn.ended`. + * `onEvent` tracks listener registrations so the test can assert the + * prompt-completion listener unsubscribes after `turn.ended`; the + * session-lifetime projection listener intentionally remains registered. */ function makeScriptedSession( sessionId: string, script: readonly Event[], ): { session: Session; + emit: (event: Event) => void; + listenerCount: () => number; + promptCalled: Promise; unsubscribeCount: () => number; } { const listeners = new Set<(event: Event) => void>(); let unsubCount = 0; + let signalPromptCalled!: () => void; + const promptCalled = new Promise((resolve) => { + signalPromptCalled = resolve; + }); + let promptId: string | undefined; + const emit = (event: Event): void => { + const correlatedEvent = + event.type === 'turn.started' && + event.origin.kind === 'user' && + event.origin.promptId === undefined + ? ({ + ...event, + origin: { ...event.origin, promptId }, + } as Event) + : event; + for (const listener of listeners) listener(correlatedEvent); + }; const session = { id: sessionId, - prompt: async (_input: unknown) => { + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { + promptId = options?.promptId; + signalPromptCalled(); + if (!script.some((event) => event.type === 'turn.started')) { + const firstTurnEvent = script.find( + (event): event is Event & { turnId: number } => + 'turnId' in event && typeof event.turnId === 'number', + ); + if (firstTurnEvent !== undefined) { + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: firstTurnEvent.turnId, + origin: { kind: 'user', promptId }, + } as Event); + } + } for (const ev of script) { - for (const fn of listeners) fn(ev); + emit(ev); } }, cancel: async () => undefined, + getContext: async () => ({ history: [], tokenCount: 0 }), onEvent: (fn: (event: Event) => void) => { listeners.add(fn); return () => { @@ -116,13 +214,20 @@ function makeScriptedSession( }; }, } as unknown as Session; - return { session, unsubscribeCount: () => unsubCount }; + return { + session, + emit, + listenerCount: () => listeners.size, + promptCalled, + unsubscribeCount: () => unsubCount, + }; } function makeHarness(session: Session): KimiHarness { return { auth: { status: async () => AUTHED_STATUS }, createSession: async () => session, + resumeSession: async () => session, // Phase 14: server.newSession reads these for configOptions. getConfig: async () => ({ providers: {}, @@ -233,9 +338,12 @@ describe('AcpServer end-to-end happy path', () => { }); expect(promptRes.stopReason).toBe('end_turn'); - // Give the agent side a tick to flush queued sessionUpdate writes - // through the ndjson stream (matching session-prompt.test.ts:128). - await new Promise((resolve) => setTimeout(resolve, 20)); + await collecting.waitForUpdate( + (notification) => + notification.update.sessionUpdate === 'agent_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text.length > 0, + ); const promptOnlyUpdates = collecting.promptUpdates; expect(promptOnlyUpdates.length).toBeGreaterThanOrEqual(1); @@ -256,6 +364,1302 @@ describe('AcpServer end-to-end happy path', () => { expect(unsubscribeCount()).toBe(1); }); + it('streams display-safe task and cron triggers with their autonomous replies', async () => { + const sessionId = 'sess-e2e-agent-initiated'; + const origin = { + kind: 'background_task', + taskId: 'task-example', + status: 'completed', + notificationId: 'task:task-example:completed', + } as const; + const privateStopReason = 'sensitive task stop detail'; + const { session, emit } = makeScriptedSession(sessionId, []); + const harness = makeHarness(session); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + const agentConnection = new AgentSideConnection( + (connection) => new AcpServer(harness, connection), + agentStream, + ); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + + emit({ + type: 'background.task.terminated', + sessionId, + agentId: 'main', + info: { + kind: 'agent', + taskId: 'task-example', + agentId: 'agent-example', + description: ' ', + status: 'completed', + detached: true, + startedAt: 1, + endedAt: 2, + stopReason: privateStopReason, + }, + } as Event); + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 2, + origin, + } as Event); + emit({ + type: 'turn.step.started', + sessionId, + agentId: 'main', + turnId: 2, + step: 1, + } as Event); + emit({ + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 2, + delta: 'Background work finished.', + } as Event); + emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 2, + reason: 'completed', + } as Event); + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 3, + origin: { + kind: 'cron_job', + jobId: 'cron-example', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + } as Event); + // v2 publishes cron.fired immediately after the injected step is assigned, + // so turn.started may precede the display event. It still arrives before + // the model's streaming output and must become the user-side chunk. + emit({ + type: 'cron.fired', + sessionId, + agentId: 'main', + origin: { + kind: 'cron_job', + jobId: 'cron-example', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + prompt: 'Review the scheduled report.', + } as Event); + emit({ + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 3, + delta: 'Scheduled review finished.', + } as Event); + emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 3, + reason: 'completed', + } as Event); + + const barrier = collecting.waitForUpdate( + (notification) => + (notification.update._meta as { barrier?: string } | null | undefined)?.barrier === + 'after-autonomous-turn', + ); + await agentConnection.sessionUpdate({ + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [], + _meta: { barrier: 'after-autonomous-turn' }, + }, + sessionId, + }); + await barrier; + + // ACP projects only the display-safe task lifecycle summary. Internal + // task identifiers and stop details must not cross the wire. + expect(collecting.promptUpdates).toEqual([ + expect.objectContaining({ + sessionId, + update: expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Background agent task completed.' }, + }), + }), + expect.objectContaining({ + sessionId, + update: expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Background work finished.' }, + }), + }), + expect.objectContaining({ + sessionId, + update: expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Review the scheduled report.' }, + }), + }), + expect.objectContaining({ + sessionId, + update: expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Scheduled review finished.' }, + }), + }), + ]); + const wire = JSON.stringify(collecting.updates); + expect(wire).not.toContain(privateStopReason); + expect(wire).not.toContain('task-example'); + expect(wire).not.toContain('agent-example'); + }); + + it('attaches a new-session bridge only after asynchronous configuration finishes', async () => { + const sessionId = 'sess-e2e-agent-initiated-setup'; + const { session, emit, listenerCount } = makeScriptedSession(sessionId, []); + let signalConfigStarted!: () => void; + const configStarted = new Promise((resolve) => { + signalConfigStarted = resolve; + }); + let releaseConfig!: () => void; + const configGate = new Promise((resolve) => { + releaseConfig = resolve; + }); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + getConfig: async () => { + signalConfigStarted(); + await configGate; + return { + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }, + ]), + }; + }, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection( + (connection) => new AcpServer(harness, connection), + agentStream, + ); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + const newSession = client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + await configStarted; + expect(listenerCount()).toBe(0); + + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 4, + origin: { + kind: 'background_task', + taskId: 'task-during-setup', + status: 'completed', + notificationId: 'task:task-during-setup:completed', + }, + } as Event); + emit({ + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 4, + delta: 'Must not precede the session response.', + } as Event); + emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 4, + reason: 'completed', + } as Event); + + releaseConfig(); + await newSession; + expect(listenerCount()).toBe(1); + expect(collecting.promptUpdates).toEqual([]); + + const postResponseUpdate = collecting.waitForUpdate( + (notification) => + (notification.update as { + sessionUpdate?: string; + content?: { type?: string; text?: string }; + }).sessionUpdate === 'agent_message_chunk' && + (notification.update as { content?: { text?: string } }).content?.text === + 'Visible after the session response.', + ); + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 5, + origin: { + kind: 'background_task', + taskId: 'task-after-setup', + status: 'completed', + notificationId: 'task:task-after-setup:completed', + }, + } as Event); + emit({ + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 5, + delta: 'Visible after the session response.', + } as Event); + emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 5, + reason: 'completed', + } as Event); + await postResponseUpdate; + + expect(collecting.promptUpdates).toEqual([ + expect.objectContaining({ + sessionId, + update: expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Visible after the session response.' }, + }), + }), + ]); + }); + + it("waits for a queued prompt's turn when an autonomous turn is already active", async () => { + const sessionId = 'sess-e2e-agent-initiated-queue'; + const { session, emit } = makeScriptedSession(sessionId, [ + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 5, + reason: 'cancelled', + } as Event, + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 6, + origin: { kind: 'user' }, + } as Event, + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 6, + reason: 'completed', + } as Event, + ]); + const harness = makeHarness(session); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + const agentConnection = new AgentSideConnection( + (connection) => new AcpServer(harness, connection), + agentStream, + ); + const client = new ClientSideConnection(() => new CollectingClient(), clientStream); + + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 5, + origin: { + kind: 'background_task', + taskId: 'task-before-prompt', + status: 'completed', + notificationId: 'task:task-before-prompt:completed', + }, + } as Event); + + await expect( + client.prompt({ sessionId, prompt: [textBlock('queued prompt')] }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + expect(agentConnection.signal.aborted).toBe(false); + }); + + it('does not complete an attached prompt from an autonomous turn that started before attach', async () => { + const sessionId = 'sess-e2e-agent-initiated-attach-mid-turn'; + const { session } = makeScriptedSession(sessionId, [ + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 20, + reason: 'cancelled', + } as Event, + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 21, + origin: { kind: 'user' }, + } as Event, + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 21, + reason: 'completed', + } as Event, + ]); + const harness = makeHarness(session); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((connection) => new AcpServer(harness, connection), agentStream); + const client = new ClientSideConnection(() => new CollectingClient(), clientStream); + + // Turn 20 began before the adapter attached, so the bridge has no + // `turn.started` fact for it. Its terminal event must still remain + // unowned rather than completing the newly submitted ACP prompt. + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + + await expect( + client.prompt({ sessionId, prompt: [textBlock('owned prompt')] }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + }); + + it('does not claim an autonomous turn that starts while the prompt is being enqueued', async () => { + const sessionId = 'sess-e2e-agent-initiated-enqueue-race'; + const { session } = makeScriptedSession(sessionId, [ + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 30, + origin: { + kind: 'background_task', + taskId: 'task-during-enqueue', + status: 'completed', + notificationId: 'task:task-during-enqueue:completed', + }, + } as Event, + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 30, + reason: 'cancelled', + } as Event, + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 31, + origin: { kind: 'user' }, + } as Event, + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 31, + reason: 'completed', + } as Event, + ]); + const harness = makeHarness(session); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((connection) => new AcpServer(harness, connection), agentStream); + const client = new ClientSideConnection(() => new CollectingClient(), clientStream); + + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + + await expect( + client.prompt({ sessionId, prompt: [textBlock('queued behind runtime work')] }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + }); + + it.each(['resume', 'load'] as const)( + 'preserves prompt ownership and tool projection across same-session %s', + async (reattachMode) => { + const sessionId = `sess-e2e-mid-tool-${reattachMode}`; + const turnId = 40; + const toolCallId = 'tool-mid-reattach'; + const { + session, + emit, + promptCalled, + } = makeScriptedSession(sessionId, []); + const harness = makeHarness(session); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + const agentConnection = new AgentSideConnection( + (connection) => new AcpServer(harness, connection), + agentStream, + ); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + const promptOutcome = client + .prompt({ sessionId, prompt: [textBlock('continue through reattach')] }) + .then( + (response) => ({ ok: true as const, response }), + (error: unknown) => ({ ok: false as const, error }), + ); + await promptCalled; + + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId, + origin: { kind: 'user' }, + } as Event); + emit({ + type: 'tool.call.delta', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Read', + argumentsPart: '{"path":', + } as Event); + await collecting.waitForUpdate( + (notification) => + (notification.update as { sessionUpdate?: string; toolCallId?: string }) + .sessionUpdate === 'tool_call' && + (notification.update as { toolCallId?: string }).toolCallId === + `${turnId}:${toolCallId}`, + ); + + if (reattachMode === 'resume') { + await client.resumeSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }); + } else { + await client.loadSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }); + } + emit({ + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Read', + args: { path: '/tmp/example.txt' }, + description: 'Reading example file', + } as Event); + emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId, + reason: 'completed', + } as Event); + + const barrier = collecting.waitForUpdate( + (notification) => + (notification.update._meta as { barrier?: string } | null | undefined)?.barrier === + `after-mid-tool-${reattachMode}`, + ); + await agentConnection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [], + _meta: { barrier: `after-mid-tool-${reattachMode}` }, + }, + }); + await barrier; + + expect(await promptOutcome).toEqual({ + ok: true, + response: { stopReason: 'end_turn' }, + }); + expect( + collecting.promptUpdates + .filter( + (notification) => + (notification.update as { toolCallId?: string }).toolCallId === + `${turnId}:${toolCallId}`, + ) + .map( + (notification) => + (notification.update as { sessionUpdate?: string }).sessionUpdate, + ), + ).toEqual(['tool_call', 'tool_call_update']); + }, + ); + + it('preserves live event order while a cold session resume is materializing', async () => { + const sessionId = 'sess-e2e-cold-resume-events'; + const { session, listenerCount } = makeScriptedSession(sessionId, []); + const rawListeners = new Set<(event: Event) => void>(); + let rawUnsubscribeCount = 0; + const preReturnEvents = [ + { + type: 'cron.fired', + sessionId, + agentId: 'main', + origin: { + kind: 'cron_job', + jobId: 'cron-example', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + prompt: 'Review the scheduled report.', + }, + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 7, + origin: { + kind: 'cron_job', + jobId: 'cron-example', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + }, + { + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 7, + delta: 'Scheduled review finished.', + }, + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 7, + reason: 'completed', + }, + ] as const satisfies readonly Event[]; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + onSessionEvent: ( + subscribedSessionId: string, + listener: (event: Event) => void, + ) => { + expect(subscribedSessionId).toBe(sessionId); + rawListeners.add(listener); + return () => { + if (!rawListeners.delete(listener)) return; + rawUnsubscribeCount += 1; + }; + }, + resumeSession: async () => { + for (const event of preReturnEvents) { + for (const listener of rawListeners) listener(event); + } + return session; + }, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }, + ]), + }), + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + const agentConnection = new AgentSideConnection( + (connection) => new AcpServer(harness, connection), + agentStream, + ); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.resumeSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }); + const barrier = collecting.waitForUpdate( + (notification) => + (notification.update._meta as { barrier?: string } | null | undefined)?.barrier === + 'after-cold-resume-events', + ); + await agentConnection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [], + _meta: { barrier: 'after-cold-resume-events' }, + }, + }); + await barrier; + + expect( + collecting.promptUpdates.map((notification) => notification.update), + ).toEqual([ + expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Review the scheduled report.' }, + }), + expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Scheduled review finished.' }, + }), + ]); + expect(rawListeners.size).toBe(0); + expect(rawUnsubscribeCount).toBe(1); + expect(listenerCount()).toBe(1); + }); + + it('holds cold-resume interactions until AcpSession can bridge them', async () => { + const sessionId = 'sess-e2e-cold-resume-interactions'; + const { session } = makeScriptedSession(sessionId, []); + let approvalHandler: ApprovalHandler | undefined; + let questionHandler: QuestionHandler | undefined; + const registerApproval = (handler: ApprovalHandler): (() => void) => { + approvalHandler = handler; + return () => { + if (approvalHandler === handler) approvalHandler = undefined; + }; + }; + const registerQuestion = (handler: QuestionHandler): (() => void) => { + questionHandler = handler; + return () => { + if (questionHandler === handler) questionHandler = undefined; + }; + }; + Object.assign(session, { + registerApprovalHandler: registerApproval, + registerQuestionHandler: registerQuestion, + }); + + let signalInteractionsStarted!: () => void; + const interactionsStarted = new Promise((resolve) => { + signalInteractionsStarted = resolve; + }); + let releaseResumeSummary!: () => void; + const resumeSummaryGate = new Promise((resolve) => { + releaseResumeSummary = resolve; + }); + let approvalOutcome: Promise | undefined; + let questionOutcome: Promise | undefined; + const approvalRequest: ApprovalRequest = { + toolCallId: 'tool-resume', + toolName: 'Bash', + action: 'run command', + display: { kind: 'command', command: 'echo ready' }, + }; + const questionRequest: QuestionRequest = { + toolCallId: 'question-resume', + questions: [ + { + question: 'Continue?', + options: [{ label: 'Yes' }, { label: 'No' }], + }, + ], + }; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + registerSessionApprovalHandler: ( + _subscribedSessionId: string, + handler: ApprovalHandler, + ) => registerApproval(handler), + registerSessionQuestionHandler: ( + _subscribedSessionId: string, + handler: QuestionHandler, + ) => registerQuestion(handler), + resumeSession: async () => { + if (approvalHandler === undefined || questionHandler === undefined) { + throw new Error('resume interactions were not registered before materialization'); + } + approvalOutcome = Promise.resolve(approvalHandler(approvalRequest)); + questionOutcome = Promise.resolve(questionHandler(questionRequest)); + signalInteractionsStarted(); + await resumeSummaryGate; + return session; + }, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }, + ]), + }), + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection( + (connection) => new AcpServer(harness, connection), + agentStream, + ); + const collecting = new InteractionClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + let resumeSettled = false; + const resume = client + .resumeSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }) + .finally(() => { + resumeSettled = true; + }); + + await interactionsStarted; + expect(resumeSettled).toBe(false); + expect(collecting.permissionRequests).toEqual([]); + + releaseResumeSummary(); + await resume; + await expect(approvalOutcome).resolves.toMatchObject({ decision: 'approved' }); + await expect(questionOutcome).resolves.toEqual({ + 'Continue?': 'Yes', + } satisfies QuestionAnswers); + expect(collecting.permissionRequests).toHaveLength(2); + + // Temporary registration cleanup is ownership-aware: it must not remove + // the permanent handler AcpSession installed during the handoff. + if (approvalHandler === undefined) { + throw new Error('AcpSession approval handler was cleared during handoff'); + } + if (questionHandler === undefined) { + throw new Error('AcpSession question handler was cleared during handoff'); + } + await expect(approvalHandler(approvalRequest)).resolves.toMatchObject({ + decision: 'approved', + }); + await expect(questionHandler(questionRequest)).resolves.toEqual({ + 'Continue?': 'Yes', + } satisfies QuestionAnswers); + expect(collecting.permissionRequests).toHaveLength(4); + }); + + it('rejects non-canonical resume ids before changing interaction ownership', async () => { + const sessionId = 'sess-e2e-canonical-resume'; + const { session } = makeScriptedSession(sessionId, []); + let approvalHandler: ApprovalHandler | undefined; + let questionHandler: QuestionHandler | undefined; + const registerApproval = (handler: ApprovalHandler): (() => void) => { + approvalHandler = handler; + return () => { + if (approvalHandler === handler) approvalHandler = undefined; + }; + }; + const registerQuestion = (handler: QuestionHandler): (() => void) => { + questionHandler = handler; + return () => { + if (questionHandler === handler) questionHandler = undefined; + }; + }; + Object.assign(session, { + registerApprovalHandler: registerApproval, + registerQuestionHandler: registerQuestion, + }); + const temporaryRegistrations: string[] = []; + const resumedIds: string[] = []; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + registerSessionApprovalHandler: ( + subscribedSessionId: string, + handler: ApprovalHandler, + ) => { + temporaryRegistrations.push(`approval:${subscribedSessionId}`); + return registerApproval(handler); + }, + registerSessionQuestionHandler: ( + subscribedSessionId: string, + handler: QuestionHandler, + ) => { + temporaryRegistrations.push(`question:${subscribedSessionId}`); + return registerQuestion(handler); + }, + resumeSession: async (input: { id: string }) => { + resumedIds.push(input.id); + return session; + }, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }, + ]), + }), + } as unknown as KimiHarness; + const server = new AcpServer( + harness, + { sessionUpdate: async () => undefined } as unknown as AgentSideConnection, + ); + + await server.resumeSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }); + const permanentApproval = approvalHandler; + const permanentQuestion = questionHandler; + expect(permanentApproval).toBeDefined(); + expect(permanentQuestion).toBeDefined(); + + await expect( + server.resumeSession({ + sessionId: ` ${sessionId} `, + cwd: '/tmp/work', + mcpServers: [], + }), + ).rejects.toMatchObject({ code: -32602 }); + await expect( + server.resumeSession({ sessionId: ' ', cwd: '/tmp/work', mcpServers: [] }), + ).rejects.toMatchObject({ code: -32602 }); + + expect(resumedIds).toEqual([sessionId]); + expect(temporaryRegistrations).toEqual([ + `approval:${sessionId}`, + `question:${sessionId}`, + ]); + expect(approvalHandler).toBe(permanentApproval); + expect(questionHandler).toBe(permanentQuestion); + server.dispose(); + }); + + it('does not replay the raw resume buffer when replacing an attached Session', async () => { + const sessionId = 'sess-e2e-replacement-resume-events'; + const { + session: originalSession, + emit: emitOriginal, + listenerCount: originalListenerCount, + unsubscribeCount: originalUnsubscribeCount, + } = makeScriptedSession(sessionId, []); + const { + session: replacementSession, + listenerCount: replacementListenerCount, + } = makeScriptedSession(sessionId, []); + const rawListeners = new Set<(event: Event) => void>(); + const event = { + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 9, + delta: 'Observed once during replacement.', + } as const satisfies Event; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => originalSession, + onSessionEvent: ( + _subscribedSessionId: string, + listener: (event: Event) => void, + ) => { + rawListeners.add(listener); + return () => { + rawListeners.delete(listener); + }; + }, + resumeSession: async () => { + // Real SDK Session listeners and harness-level listeners subscribe to + // the same RPC event multicast. Model both deliveries explicitly. + emitOriginal(event); + for (const listener of rawListeners) listener(event); + return replacementSession; + }, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }, + ]), + }), + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + const agentConnection = new AgentSideConnection( + (connection) => new AcpServer(harness, connection), + agentStream, + ); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + await client.resumeSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }); + const barrier = collecting.waitForUpdate( + (notification) => + (notification.update._meta as { barrier?: string } | null | undefined)?.barrier === + 'after-replacement-resume-events', + ); + await agentConnection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [], + _meta: { barrier: 'after-replacement-resume-events' }, + }, + }); + await barrier; + + expect( + collecting.promptUpdates.filter( + (notification) => + (notification.update as { sessionUpdate?: string }).sessionUpdate === + 'agent_message_chunk', + ), + ).toEqual([ + expect.objectContaining({ + sessionId, + update: expect.objectContaining({ + content: { type: 'text', text: 'Observed once during replacement.' }, + }), + }), + ]); + expect(rawListeners.size).toBe(0); + expect(originalListenerCount()).toBe(0); + expect(originalUnsubscribeCount()).toBe(1); + expect(replacementListenerCount()).toBe(1); + }); + + it('serializes concurrent same-session resume setup through configuration', async () => { + const sessionId = 'sess-e2e-concurrent-resume'; + const { session } = makeScriptedSession(sessionId, []); + const rawListeners = new Set<(event: Event) => void>(); + const updates: SessionNotification[] = []; + let activeSession: Session | undefined; + let configCalls = 0; + let signalConfigStarted!: () => void; + const configStarted = new Promise((resolve) => { + signalConfigStarted = resolve; + }); + let releaseFirstConfig!: () => void; + const firstConfigGate = new Promise((resolve) => { + releaseFirstConfig = resolve; + }); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + onSessionEvent: ( + _subscribedSessionId: string, + listener: (event: Event) => void, + ) => { + rawListeners.add(listener); + return () => { + rawListeners.delete(listener); + }; + }, + resumeSession: async () => { + if (activeSession !== undefined) return activeSession; + activeSession = session; + const event = { + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 10, + delta: 'Concurrent resume output.', + } as const satisfies Event; + for (const listener of rawListeners) listener(event); + return session; + }, + getConfig: async () => { + configCalls += 1; + if (configCalls === 1) { + signalConfigStarted(); + await firstConfigGate; + } + return { + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }, + ]), + }; + }, + } as unknown as KimiHarness; + const server = new AcpServer( + harness, + { + sessionUpdate: async (notification: SessionNotification) => { + updates.push(notification); + }, + } as unknown as AgentSideConnection, + ); + + const first = server.resumeSession({ + sessionId, + cwd: '/tmp/work', + mcpServers: [], + }); + await configStarted; + const second = server.resumeSession({ + sessionId, + cwd: '/tmp/work', + mcpServers: [], + }); + await new Promise((resolve) => setImmediate(resolve)); + + // The second request has been dispatched, but must not enter any setup + // collaborator while the first request owns this session's critical + // section. + expect(configCalls).toBe(1); + + releaseFirstConfig(); + await Promise.all([first, second]); + + expect( + updates.filter( + (notification) => + (notification.update as { sessionUpdate?: string }).sessionUpdate === + 'agent_message_chunk', + ), + ).toEqual([ + expect.objectContaining({ + sessionId, + update: expect.objectContaining({ + content: { type: 'text', text: 'Concurrent resume output.' }, + }), + }), + ]); + expect(rawListeners.size).toBe(0); + }); + + it.each(['new', 'resume'] as const)( + 'does not attach a session event bridge when the server is disposed during %s setup', + async (mode) => { + const sessionId = `sess-e2e-dispose-during-${mode}`; + const { session, listenerCount } = makeScriptedSession(sessionId, []); + let signalSetupStarted: (() => void) | undefined; + const setupStarted = new Promise((resolve) => { + signalSetupStarted = resolve; + }); + let releaseSetup: (() => void) | undefined; + const setupGate = new Promise((resolve) => { + releaseSetup = resolve; + }); + const rawListeners = new Set<(event: Event) => void>(); + const delayedSession = async (): Promise => { + signalSetupStarted?.(); + await setupGate; + return session; + }; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: delayedSession, + resumeSession: delayedSession, + onSessionEvent: (_sessionId: string, listener: (event: Event) => void) => { + rawListeners.add(listener); + return () => { + rawListeners.delete(listener); + }; + }, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }, + ]), + }), + } as unknown as KimiHarness; + const connection = { + sessionUpdate: async () => undefined, + } as unknown as AgentSideConnection; + const server = new AcpServer(harness, connection); + const setup = + mode === 'new' + ? server.newSession({ cwd: '/tmp/work', mcpServers: [] }) + : server.resumeSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }); + const outcome = setup.then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); + + await setupStarted; + expect(rawListeners.size).toBe(mode === 'resume' ? 1 : 0); + server.dispose(); + expect(rawListeners.size).toBe(0); + releaseSetup?.(); + + await expect(outcome).resolves.toMatchObject({ + ok: false, + error: { code: -32603 }, + }); + expect(listenerCount()).toBe(0); + }, + ); + + it('releases the temporary event bridge when a cold resume fails', async () => { + const sessionId = 'sess-e2e-cold-resume-failure'; + const rawListeners = new Set<(event: Event) => void>(); + let rawUnsubscribeCount = 0; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + onSessionEvent: ( + _subscribedSessionId: string, + listener: (event: Event) => void, + ) => { + rawListeners.add(listener); + return () => { + if (!rawListeners.delete(listener)) return; + rawUnsubscribeCount += 1; + }; + }, + resumeSession: async () => { + throw new Error('resume unavailable'); + }, + } as unknown as KimiHarness; + const server = new AcpServer( + harness, + { sessionUpdate: async () => undefined } as unknown as AgentSideConnection, + ); + + await expect( + server.resumeSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }), + ).rejects.toThrow('resume unavailable'); + + expect(rawListeners.size).toBe(0); + expect(rawUnsubscribeCount).toBe(1); + expect(server.getSession(sessionId)).toBeUndefined(); + }); + + it('rolls back interaction handlers when cold-resume event registration fails', async () => { + const sessionId = 'sess-e2e-cold-resume-registration-failure'; + let currentApproval: ApprovalHandler | undefined; + let currentQuestion: QuestionHandler | undefined; + let capturedApproval: ApprovalHandler | undefined; + let capturedQuestion: QuestionHandler | undefined; + let approvalReleaseCount = 0; + let questionReleaseCount = 0; + let resumeCallCount = 0; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + registerSessionApprovalHandler: ( + _subscribedSessionId: string, + handler: ApprovalHandler, + ) => { + currentApproval = handler; + capturedApproval = handler; + return () => { + if (currentApproval !== handler) return; + currentApproval = undefined; + approvalReleaseCount += 1; + }; + }, + registerSessionQuestionHandler: ( + _subscribedSessionId: string, + handler: QuestionHandler, + ) => { + currentQuestion = handler; + capturedQuestion = handler; + return () => { + if (currentQuestion !== handler) return; + currentQuestion = undefined; + questionReleaseCount += 1; + }; + }, + onSessionEvent: () => { + throw new Error('event registration unavailable'); + }, + resumeSession: async () => { + resumeCallCount += 1; + throw new Error('resume should not run'); + }, + } as unknown as KimiHarness; + const server = new AcpServer( + harness, + { sessionUpdate: async () => undefined } as unknown as AgentSideConnection, + ); + + await expect( + server.resumeSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }), + ).rejects.toThrow('event registration unavailable'); + + expect(resumeCallCount).toBe(0); + expect(currentApproval).toBeUndefined(); + expect(currentQuestion).toBeUndefined(); + expect(approvalReleaseCount).toBe(1); + expect(questionReleaseCount).toBe(1); + if (capturedApproval === undefined || capturedQuestion === undefined) { + throw new Error('temporary interaction handlers were not registered'); + } + await expect( + capturedApproval({ + toolCallId: 'tool-registration-failure', + toolName: 'Bash', + action: 'run command', + display: { kind: 'command', command: 'echo ready' }, + }), + ).resolves.toEqual({ + decision: 'cancelled', + feedback: 'ACP session setup did not complete.', + } satisfies ApprovalResponse); + await expect( + capturedQuestion({ + toolCallId: 'question-registration-failure', + questions: [{ question: 'Continue?', options: [{ label: 'Yes' }] }], + }), + ).resolves.toBeNull(); + }); + + it('removes a newly attached resume bridge when configuration setup fails', async () => { + const sessionId = 'sess-e2e-resume-config-failure'; + const { + session, + listenerCount, + unsubscribeCount, + } = makeScriptedSession(sessionId, []); + const models = new Proxy( + {}, + { + ownKeys: () => { + throw new Error('catalog unavailable'); + }, + }, + ); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + resumeSession: async () => session, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models, + }), + } as unknown as KimiHarness; + const connection = { + sessionUpdate: async () => undefined, + } as unknown as AgentSideConnection; + const server = new AcpServer(harness, connection); + + await expect( + server.resumeSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }), + ).rejects.toThrow('catalog unavailable'); + + expect(server.getSession(sessionId)).toBeUndefined(); + expect(listenerCount()).toBe(0); + expect(unsubscribeCount()).toBe(1); + }); + + it('releases the session event bridge when the ACP transport closes', async () => { + const sessionId = 'sess-e2e-agent-initiated-close'; + const { session, listenerCount, unsubscribeCount } = makeScriptedSession(sessionId, []); + const harness = makeHarness(session); + + const { + agentStream, + clientStream, + closeAgentInput, + closeClientInput, + } = makeInMemoryStreamPair(); + const run = runAcpServerWithStream(harness, agentStream); + const client = new ClientSideConnection(() => new CollectingClient(), clientStream); + + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + expect(listenerCount()).toBe(1); + + await closeAgentInput(); + await run; + + expect(listenerCount()).toBe(0); + expect(unsubscribeCount()).toBe(1); + + await closeClientInput(); + await client.closed; + }); + it('cancel mid-stream resolves with stopReason cancelled', async () => { const sessionId = 'sess-e2e-cancel'; // Scripted session that emits one delta, then a cancelled diff --git a/packages/acp-adapter/test/error-mapping.test.ts b/packages/acp-adapter/test/error-mapping.test.ts index f05bfef128..da2574889e 100644 --- a/packages/acp-adapter/test/error-mapping.test.ts +++ b/packages/acp-adapter/test/error-mapping.test.ts @@ -71,13 +71,43 @@ function makeScriptedSession( ): ScriptedSession { const listeners = new Set<(event: Event) => void>(); let unsubCount = 0; + const emit = (event: Event, promptId: string | undefined): void => { + const correlatedEvent = + event.type === 'turn.started' && + event.origin.kind === 'user' && + event.origin.promptId === undefined + ? ({ ...event, origin: { ...event.origin, promptId } } as Event) + : event; + for (const fn of listeners) fn(correlatedEvent); + }; const session = { id: sessionId, - prompt: async (_input: unknown) => { + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { if (opts.rejectWith) throw opts.rejectWith; if (opts.script) { + if (!opts.script.some((event) => event.type === 'turn.started')) { + const firstTurnEvent = opts.script.find( + (event): event is Event & { turnId: number } => + 'turnId' in event && typeof event.turnId === 'number', + ); + if (firstTurnEvent !== undefined) { + emit( + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: firstTurnEvent.turnId, + origin: { kind: 'user', promptId: options?.promptId }, + } as Event, + options?.promptId, + ); + } + } for (const ev of opts.script) { - for (const fn of listeners) fn(ev); + emit(ev, options?.promptId); } } }, diff --git a/packages/acp-adapter/test/plan-and-commands.test.ts b/packages/acp-adapter/test/plan-and-commands.test.ts index cc5463d736..4dc8cf27e5 100644 --- a/packages/acp-adapter/test/plan-and-commands.test.ts +++ b/packages/acp-adapter/test/plan-and-commands.test.ts @@ -74,11 +74,30 @@ function makeInMemoryStreamPair(): { function makeScriptedSession(sessionId: string, script: readonly Event[]): Session { const listeners = new Set<(event: Event) => void>(); + const emit = (event: Event): void => { + for (const fn of listeners) fn(event); + }; const session = { id: sessionId, - prompt: async (_input: unknown) => { + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { + const firstTurnEvent = script.find( + (event): event is Event & { turnId: number } => + 'turnId' in event && typeof event.turnId === 'number', + ); + if (firstTurnEvent !== undefined) { + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: firstTurnEvent.turnId, + origin: { kind: 'user', promptId: options?.promptId }, + } as Event); + } for (const ev of script) { - for (const fn of listeners) fn(ev); + emit(ev); } }, cancel: async () => undefined, diff --git a/packages/acp-adapter/test/prompt-admission-v2.e2e.test.ts b/packages/acp-adapter/test/prompt-admission-v2.e2e.test.ts new file mode 100644 index 0000000000..041c50be5d --- /dev/null +++ b/packages/acp-adapter/test/prompt-admission-v2.e2e.test.ts @@ -0,0 +1,142 @@ +/** + * Scenario: a v2 UserPromptSubmit hook completes an ACP prompt before any turn launches. + * Responsibilities: settle the correlated ACP request and release admission for later prompts. + * Wiring: real v2 harness, hook process, node SDK, ACP NDJSON, and loopback model protocol; + * only the remote Chat Completions endpoint is stubbed. + * Run: pnpm --filter @moonshot-ai/acp-adapter exec vitest run test/prompt-admission-v2.e2e.test.ts + */ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import type { Event } from '@moonshot-ai/kimi-code-sdk'; + +import { + createRealEngineRig, + type RealEngineRig, +} from './_helpers/real-engine-rig'; + +const BLOCKING_HOOK_CONFIG = ` +[[hooks]] +event = "UserPromptSubmit" +matcher = "block this request" +command = "node -e \\"process.stderr.write('blocked by test');process.exit(2)\\"" +timeout = 5 +`; + +let rig: RealEngineRig | undefined; +const eventSubscriptions: Array<() => void> = []; + +afterEach(async () => { + for (const unsubscribe of eventSubscriptions.splice(0)) { + unsubscribe(); + } + try { + await rig?.close(); + } finally { + rig = undefined; + } +}); + +describe('ACP v2 no-turn prompt admission', () => { + it( + 'returns refusal when the hook blocks before turn launch', + async () => { + rig = await createAdmissionRig(); + const events = collectEvents(rig); + + await expect( + rig.client.prompt({ + sessionId: rig.session.id, + prompt: [{ type: 'text', text: 'block this request' }], + }), + ).resolves.toEqual({ stopReason: 'refusal' }); + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'prompt.completed', + promptId: expect.any(String), + reason: 'blocked', + }), + ); + expect(events.some((event) => event.type === 'turn.started')).toBe(false); + expect(rig.modelRequests).toHaveLength(0); + }, + 30_000, + ); + + it( + 'admits a later request after the hook blocks the preceding prompt', + async () => { + rig = await createAdmissionRig(); + const events = collectEvents(rig); + await rig.client.prompt({ + sessionId: rig.session.id, + prompt: [{ type: 'text', text: 'block this request' }], + }); + const reply = rig.collecting.waitForUpdate( + (notification) => + notification.update.sessionUpdate === 'agent_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === 'Later prompt completed.', + 'later prompt assistant reply', + ); + + await expect( + rig.client.prompt({ + sessionId: rig.session.id, + prompt: [{ type: 'text', text: 'allow this request' }], + }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + await reply; + + const completedIndex = events.findIndex( + (event) => event.type === 'prompt.completed' && event.reason === 'blocked', + ); + const startedIndex = events.findIndex((event) => event.type === 'turn.started'); + const endedIndex = events.findIndex((event) => event.type === 'turn.ended'); + expect(completedIndex).toBeGreaterThanOrEqual(0); + expect(startedIndex).toBeGreaterThan(completedIndex); + expect(endedIndex).toBeGreaterThan(startedIndex); + expect(rig.modelRequests).toHaveLength(1); + expect(rig.modelRequests[0]).toMatchObject({ + authorization: 'Bearer YOUR_API_KEY', + body: { + model: 'stub-model', + stream: true, + messages: expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + content: expect.stringContaining('allow this request'), + }), + ]), + }, + }); + }, + 30_000, + ); +}); + +async function createAdmissionRig(): Promise { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-acp-v2-hook-home-')); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-acp-v2-hook-work-')); + return createRealEngineRig({ + engine: 'v2', + homeDir, + workDir, + replies: [{ kind: 'text', text: 'Later prompt completed.' }], + additionalConfig: BLOCKING_HOOK_CONFIG, + }); +} + +function collectEvents(target: RealEngineRig): Event[] { + const events: Event[] = []; + eventSubscriptions.push( + target.session.onEvent((event) => { + events.push(event); + }), + ); + return events; +} diff --git a/packages/acp-adapter/test/session-prompt.test.ts b/packages/acp-adapter/test/session-prompt.test.ts index 048fd57f06..3f70246a42 100644 --- a/packages/acp-adapter/test/session-prompt.test.ts +++ b/packages/acp-adapter/test/session-prompt.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: ACP prompt admission, turn correlation, terminal mapping, and cleanup. + * Responsibilities: one request owns only its correlated main-agent turn or no-turn completion. + * Wiring: scripted SDK Session events through AcpSession/AcpServer and in-memory ACP NDJSON. + * Run: pnpm --filter @moonshot-ai/acp-adapter exec vitest run test/session-prompt.test.ts + */ import { describe, expect, it } from 'vitest'; import { @@ -14,13 +20,24 @@ import { type WriteTextFileRequest, type WriteTextFileResponse, } from '@agentclientprotocol/sdk'; -import type { Event, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import type { + ApprovalHandler, + Event, + KimiHarness, + QuestionHandler, + Session, +} from '@moonshot-ai/kimi-code-sdk'; import { AcpServer } from '../src/server'; +import { AcpSession } from '../src/session'; import { AUTHED_STATUS } from './_helpers/harness-stubs'; class CollectingClient implements Client { readonly updates: SessionNotification[] = []; + private readonly promptUpdateWaiters = new Set<{ + readonly count: number; + readonly resolve: () => void; + }>(); /** * Updates produced AFTER `session/new` returns. Phase 9.3 makes @@ -41,6 +58,12 @@ class CollectingClient implements Client { } async sessionUpdate(n: SessionNotification): Promise { this.updates.push(n); + const promptUpdateCount = this.promptUpdates.length; + for (const waiter of this.promptUpdateWaiters) { + if (promptUpdateCount < waiter.count) continue; + this.promptUpdateWaiters.delete(waiter); + waiter.resolve(); + } } async writeTextFile(_p: WriteTextFileRequest): Promise { throw new Error('CollectingClient.writeTextFile should not be called in prompt test'); @@ -48,6 +71,13 @@ class CollectingClient implements Client { async readTextFile(_p: ReadTextFileRequest): Promise { throw new Error('CollectingClient.readTextFile should not be called in prompt test'); } + + waitForPromptUpdates(count: number): Promise { + if (this.promptUpdates.length >= count) return Promise.resolve(); + return new Promise((resolve) => { + this.promptUpdateWaiters.add({ count, resolve }); + }); + } } function makeInMemoryStreamPair(): { @@ -74,13 +104,43 @@ function makeScriptedSession( } { const listeners = new Set<(event: Event) => void>(); let unsubCount = 0; + const emit = (event: Event, promptId: string | undefined): void => { + const correlatedEvent = + event.type === 'turn.started' && + event.origin.kind === 'user' && + event.origin.promptId === undefined + ? ({ ...event, origin: { ...event.origin, promptId } } as Event) + : event; + for (const fn of listeners) fn(correlatedEvent); + }; const session = { id: sessionId, - prompt: async (_input: unknown) => { + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { // Emit asynchronously so the caller has time to set `settled` // before the first event lands (matches real RPC ordering). + if (!script.some((event) => event.type === 'turn.started')) { + const firstTurnEvent = script.find( + (event): event is Event & { turnId: number } => + 'turnId' in event && typeof event.turnId === 'number', + ); + if (firstTurnEvent !== undefined) { + emit( + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: firstTurnEvent.turnId, + origin: { kind: 'user', promptId: options?.promptId }, + } as Event, + options?.promptId, + ); + } + } for (const ev of script) { - for (const fn of listeners) fn(ev); + emit(ev, options?.promptId); } }, cancel: async () => undefined, @@ -97,6 +157,68 @@ function makeScriptedSession( const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); +function makeControlledAdmissionSession( + sessionId: string, + onPrompt: ( + promptId: string, + call: number, + emit: (event: Event) => void, + ) => Promise | void, +): { + readonly session: Session; + readonly listeners: ReadonlySet<(event: Event) => void>; + emit(event: Event): void; +} { + const listeners = new Set<(event: Event) => void>(); + let promptCalls = 0; + const emit = (event: Event): void => { + for (const listener of [...listeners]) listener(event); + }; + return { + session: { + id: sessionId, + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { + const promptId = options?.promptId; + if (promptId === undefined) throw new Error('ACP did not correlate the SDK prompt'); + promptCalls += 1; + await onPrompt(promptId, promptCalls, emit); + }, + cancel: async () => undefined, + onEvent: (listener: (event: Event) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + } as unknown as Session, + listeners, + emit, + }; +} + +function promptCompletedEvent( + sessionId: string, + promptId: string, + reason: 'completed' | 'failed' | 'blocked', +): Event { + return { + type: 'prompt.completed', + sessionId, + agentId: 'main', + promptId, + finishedAt: '2026-01-01T00:00:00.000Z', + reason, + } as Event; +} + +function directAcpSession(session: Session): AcpSession { + return new AcpSession( + { sessionUpdate: async () => undefined } as unknown as AgentSideConnection, + session, + ); +} + describe('AcpServer session/prompt', () => { it('streams two AssistantDelta events as agent_message_chunk updates and resolves with end_turn', async () => { const sessionId = 'sess-A'; @@ -117,16 +239,14 @@ describe('AcpServer session/prompt', () => { await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + const updatesReceived = collecting.waitForPromptUpdates(2); const response = await client.prompt({ sessionId, prompt: [textBlock('hi')], }); expect(response.stopReason).toBe('end_turn'); - - // Give the agent side a tick to flush queued sessionUpdate writes - // through the ndjson stream. - await new Promise((resolve) => setTimeout(resolve, 20)); + await updatesReceived; expect(collecting.promptUpdates).toHaveLength(2); for (const note of collecting.promptUpdates) { @@ -276,7 +396,10 @@ describe('AcpServer session/prompt', () => { }); const session = { id: sessionId, - prompt: async (_input: unknown) => { + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { promptCall += 1; await Promise.resolve(); if (promptCall === 1) { @@ -286,7 +409,7 @@ describe('AcpServer session/prompt', () => { sessionId, agentId: 'main', turnId: 1, - origin: { kind: 'user' }, + origin: { kind: 'user', promptId: options?.promptId }, } as unknown as Event); } await firstTurn; @@ -393,14 +516,318 @@ describe('AcpServer session/prompt', () => { await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + const updatesReceived = collecting.waitForPromptUpdates(2); const response = await client.prompt({ sessionId, prompt: [textBlock('hi')], }); expect(response.stopReason).toBe('end_turn'); - await new Promise((resolve) => setTimeout(resolve, 20)); + await updatesReceived; expect(collecting.promptUpdates).toHaveLength(2); expect(unsubscribeCount()).toBe(1); }); + + it('dispose rejects active and queued prompt admissions and releases their listeners', async () => { + const sessionId = 'sess-dispose-prompts'; + const listeners = new Set<(event: Event) => void>(); + let promptCallCount = 0; + let resolveFirstPromptCalled: (() => void) | undefined; + const firstPromptCalled = new Promise((resolve) => { + resolveFirstPromptCalled = resolve; + }); + const session = { + id: sessionId, + prompt: () => { + promptCallCount += 1; + resolveFirstPromptCalled?.(); + return new Promise(() => undefined); + }, + cancel: async () => undefined, + onEvent: (listener: (event: Event) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + } as unknown as Session; + const connection = { + sessionUpdate: async () => undefined, + } as unknown as AgentSideConnection; + const acpSession = new AcpSession(connection, session); + + const firstResult = acpSession + .prompt([textBlock('first')]) + .then( + (response) => response, + (error: unknown) => error, + ); + const secondResult = acpSession + .prompt([textBlock('second')]) + .then( + (response) => response, + (error: unknown) => error, + ); + + await firstPromptCalled; + expect(promptCallCount).toBe(1); + + acpSession.dispose(); + + await expect(firstResult).resolves.toMatchObject({ code: -32603 }); + await expect(secondResult).resolves.toMatchObject({ code: -32603 }); + expect(listeners.size).toBe(0); + }); + + it('dispose is idempotent for the session-lifetime event subscription', () => { + const listeners = new Set<(event: Event) => void>(); + let unsubscribeCount = 0; + const session = { + id: 'sess-dispose-idempotent', + cancel: async () => undefined, + onEvent: (listener: (event: Event) => void) => { + listeners.add(listener); + return () => { + unsubscribeCount += 1; + listeners.delete(listener); + }; + }, + } as unknown as Session; + const acpSession = directAcpSession(session); + + acpSession.dispose(); + acpSession.dispose(); + + expect(unsubscribeCount).toBe(1); + expect(listeners.size).toBe(0); + }); + + it('releases owned interaction handlers when event registration fails', () => { + let approvalHandler: ApprovalHandler | undefined; + let questionHandler: QuestionHandler | undefined; + let approvalReleases = 0; + let questionReleases = 0; + const session = { + id: 'sess-construction-cleanup', + registerApprovalHandler: (handler: ApprovalHandler) => { + approvalHandler = handler; + return () => { + approvalReleases += 1; + if (approvalHandler === handler) approvalHandler = undefined; + }; + }, + registerQuestionHandler: (handler: QuestionHandler) => { + questionHandler = handler; + return () => { + questionReleases += 1; + if (questionHandler === handler) questionHandler = undefined; + }; + }, + onEvent: () => { + throw new Error('event registration failed'); + }, + } as unknown as Session; + + expect( + () => new AcpSession({} as AgentSideConnection, session), + ).toThrow('event registration failed'); + + expect(approvalHandler).toBeUndefined(); + expect(questionHandler).toBeUndefined(); + expect(approvalReleases).toBe(1); + expect(questionReleases).toBe(1); + }); + + it('rejects prompts after dispose without calling the SDK session', async () => { + let promptCallCount = 0; + const session = { + id: 'sess-prompt-after-dispose', + prompt: () => { + promptCallCount += 1; + return Promise.resolve(); + }, + cancel: async () => undefined, + onEvent: () => () => undefined, + } as unknown as Session; + const acpSession = directAcpSession(session); + acpSession.dispose(); + + await expect(acpSession.prompt([textBlock('after dispose')])).rejects.toMatchObject({ + code: -32603, + }); + await expect(acpSession.prompt([textBlock('/help')])).rejects.toMatchObject({ + code: -32603, + }); + expect(promptCallCount).toBe(0); + }); + + it.each([ + { reason: 'blocked' as const, wrongReason: 'failed' as const, stopReason: 'refusal' as const }, + { reason: 'failed' as const, wrongReason: 'blocked' as const, stopReason: 'end_turn' as const }, + ])( + 'settles a correlated no-turn $reason completion and ignores another prompt id', + async ({ reason, wrongReason, stopReason }) => { + const sessionId = `sess-no-turn-${reason}`; + const controlled = makeControlledAdmissionSession( + sessionId, + (promptId, _call, emit) => { + emit(promptCompletedEvent(sessionId, 'different-prompt', wrongReason)); + emit(promptCompletedEvent(sessionId, promptId, reason)); + }, + ); + const acpSession = directAcpSession(controlled.session); + + await expect(acpSession.prompt([textBlock('hello')])).resolves.toEqual({ stopReason }); + expect(controlled.listeners.size).toBe(1); + acpSession.dispose(); + expect(controlled.listeners.size).toBe(0); + }, + ); + + it('ignores prompt.completed after the correlated turn has started', async () => { + const sessionId = 'sess-completion-after-turn'; + const controlled = makeControlledAdmissionSession( + sessionId, + (promptId, _call, emit) => { + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 7, + origin: { kind: 'user', promptId }, + } as Event); + emit(promptCompletedEvent(sessionId, promptId, 'blocked')); + emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 7, + reason: 'completed', + } as Event); + }, + ); + const acpSession = directAcpSession(controlled.session); + + await expect(acpSession.prompt([textBlock('hello')])).resolves.toEqual({ + stopReason: 'end_turn', + }); + expect(controlled.listeners.size).toBe(1); + acpSession.dispose(); + }); + + it('waits for turn.ended when the launch promise rejects after the correlated turn starts', async () => { + const sessionId = 'sess-kick-rejects-after-start'; + const listeners = new Set<(event: Event) => void>(); + const emit = (event: Event): void => { + for (const listener of listeners) listener(event); + }; + let rejectKick: ((error: Error) => void) | undefined; + const kickResult = new Promise((_resolve, reject) => { + rejectKick = reject; + }); + let resolveTurnStarted: (() => void) | undefined; + const turnStarted = new Promise((resolve) => { + resolveTurnStarted = resolve; + }); + const session = { + id: sessionId, + prompt: ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { + const promptId = options?.promptId; + if (promptId === undefined) throw new Error('ACP did not correlate the SDK prompt'); + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 8, + origin: { kind: 'user', promptId }, + } as Event); + resolveTurnStarted?.(); + return kickResult; + }, + cancel: async () => undefined, + onEvent: (listener: (event: Event) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + } as unknown as Session; + const acpSession = directAcpSession(session); + const outcome = acpSession.prompt([textBlock('hello')]).then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + + await turnStarted; + rejectKick?.(new Error('metadata update failed after launch')); + await Promise.resolve(); + emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 8, + reason: 'completed', + } as Event); + + await expect(outcome).resolves.toEqual({ + response: { stopReason: 'end_turn' }, + }); + acpSession.dispose(); + }); + + it('settles once when duplicate no-turn completions race a rejected kick', async () => { + const sessionId = 'sess-completion-kick-race'; + const controlled = makeControlledAdmissionSession( + sessionId, + async (promptId, _call, emit) => { + emit(promptCompletedEvent(sessionId, promptId, 'blocked')); + emit(promptCompletedEvent(sessionId, promptId, 'failed')); + throw new Error('late kick rejection'); + }, + ); + const acpSession = directAcpSession(controlled.session); + + await expect(acpSession.prompt([textBlock('hello')])).resolves.toEqual({ + stopReason: 'refusal', + }); + await Promise.resolve(); + expect(controlled.listeners.size).toBe(1); + acpSession.dispose(); + }); + + it('advances a queued prompt after a no-turn completion releases admission', async () => { + const sessionId = 'sess-no-turn-queue'; + let resolveFirstKicked: ((promptId: string) => void) | undefined; + const firstKicked = new Promise((resolve) => { + resolveFirstKicked = resolve; + }); + let resolveSecondKicked: (() => void) | undefined; + const secondKicked = new Promise((resolve) => { + resolveSecondKicked = resolve; + }); + const controlled = makeControlledAdmissionSession( + sessionId, + (promptId, call, emit) => { + if (call === 1) { + resolveFirstKicked?.(promptId); + return new Promise(() => undefined); + } + emit(promptCompletedEvent(sessionId, promptId, 'failed')); + resolveSecondKicked?.(); + }, + ); + const acpSession = directAcpSession(controlled.session); + + const first = acpSession.prompt([textBlock('first')]); + const second = acpSession.prompt([textBlock('second')]); + const firstPromptId = await firstKicked; + controlled.emit(promptCompletedEvent(sessionId, firstPromptId, 'blocked')); + + await expect(first).resolves.toEqual({ stopReason: 'refusal' }); + await secondKicked; + await expect(second).resolves.toEqual({ stopReason: 'end_turn' }); + expect(controlled.listeners.size).toBe(1); + acpSession.dispose(); + }); }); diff --git a/packages/acp-adapter/test/session-slash.test.ts b/packages/acp-adapter/test/session-slash.test.ts index f13dea0b1d..28015182bb 100644 --- a/packages/acp-adapter/test/session-slash.test.ts +++ b/packages/acp-adapter/test/session-slash.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: ACP slash-command routing and concurrent skill activations. + * Responsibilities: route known skill commands locally and correlate each ACP request to its turn. + * Wiring: real ACP NDJSON connections; only the node SDK Session boundary is scripted. + * Run: pnpm --filter @moonshot-ai/acp-adapter exec vitest run test/session-slash.test.ts + */ import { describe, expect, it } from 'vitest'; import { @@ -21,11 +27,21 @@ import { AUTHED_STATUS } from './_helpers/harness-stubs'; class CollectingClient implements Client { readonly updates: SessionNotification[] = []; + private readonly updateWaiters = new Set<{ + readonly predicate: (notification: SessionNotification) => boolean; + readonly resolve: (notification: SessionNotification) => void; + }>(); + async requestPermission(_p: RequestPermissionRequest): Promise { throw new Error('requestPermission should not be called'); } async sessionUpdate(n: SessionNotification): Promise { this.updates.push(n); + for (const waiter of this.updateWaiters) { + if (!waiter.predicate(n)) continue; + this.updateWaiters.delete(waiter); + waiter.resolve(n); + } } async writeTextFile(_p: WriteTextFileRequest): Promise { throw new Error('writeTextFile should not be called'); @@ -33,6 +49,16 @@ class CollectingClient implements Client { async readTextFile(_p: ReadTextFileRequest): Promise { throw new Error('readTextFile should not be called'); } + + waitForUpdate( + predicate: (notification: SessionNotification) => boolean, + ): Promise { + const existing = this.updates.find(predicate); + if (existing !== undefined) return Promise.resolve(existing); + return new Promise((resolve) => { + this.updateWaiters.add({ predicate, resolve }); + }); + } } function makeInMemoryStreamPair(): { @@ -72,21 +98,57 @@ function makeFakeSession( prompt: 0, activate: [] as Array<{ name: string; args?: string | undefined }>, }; - const emit = async (): Promise => { + const emit = async ( + origin: + | { readonly kind: 'user'; readonly promptId?: string } + | { + readonly kind: 'skill_activation'; + readonly activationId?: string; + readonly skillName: string; + readonly trigger: 'user-slash'; + }, + ): Promise => { await Promise.resolve(); + const firstTurnEvent = script.find( + (event): event is Event & { turnId: number } => + 'turnId' in event && typeof event.turnId === 'number', + ); + if (firstTurnEvent !== undefined) { + for (const fn of listeners) { + fn({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: firstTurnEvent.turnId, + origin, + } as Event); + } + } for (const ev of script) { for (const fn of listeners) fn(ev); } }; const session = { id: sessionId, - prompt: async (_input: unknown) => { + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { calls.prompt += 1; - await emit(); + await emit({ kind: 'user', promptId: options?.promptId }); }, - activateSkill: async (name: string, args?: string | undefined) => { + activateSkill: async ( + name: string, + args?: string | undefined, + options?: { readonly activationId?: string }, + ) => { calls.activate.push({ name, args }); - await emit(); + await emit({ + kind: 'skill_activation', + activationId: options?.activationId, + skillName: name, + trigger: 'user-slash', + }); }, cancel: async () => undefined, onEvent: (fn: (event: Event) => void) => { @@ -114,30 +176,14 @@ function endedTurn(sessionId: string): Event { return { type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'completed' } as Event; } -/** - * Wait for the client to receive an `available_commands_update` push. - * The server schedules it via `setTimeout(0)` after `session/new` - * resolves, so we need a microtask boundary before sending a prompt - * that relies on the per-session `skillCommandMap` being seeded. - */ async function waitForAvailableCommands( collecting: CollectingClient, - timeoutMs = 200, ): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if ( - collecting.updates.some( - (n) => - (n.update as { sessionUpdate?: string }).sessionUpdate === - 'available_commands_update', - ) - ) { - return; - } - await new Promise((r) => setTimeout(r, 5)); - } - throw new Error('available_commands_update never arrived'); + await collecting.waitForUpdate( + (notification) => + (notification.update as { sessionUpdate?: string }).sessionUpdate === + 'available_commands_update', + ); } describe('AcpSession slash routing', () => { @@ -222,6 +268,167 @@ describe('AcpSession slash routing', () => { expect(calls.activate).toEqual([{ name: 'foo', args: undefined }]); }); + it('keeps concurrent activations of the same skill bound to their correlation ids', async () => { + const sessionId = 'sess-slash-same-skill-concurrent'; + const listeners = new Set<(event: Event) => void>(); + const activationCalls: Array<{ + readonly args?: string; + readonly activationId?: string; + }> = []; + let signalFirstActivation!: () => void; + let signalSecondActivation!: () => void; + const firstActivation = new Promise((resolve) => { + signalFirstActivation = resolve; + }); + const secondActivation = new Promise((resolve) => { + signalSecondActivation = resolve; + }); + const emit = (event: Event): void => { + for (const listener of listeners) listener(event); + }; + const session = { + id: sessionId, + prompt: async () => { + throw new Error('plain prompt should not run for a known skill command'); + }, + activateSkill: async ( + _name: string, + args?: string, + options?: { readonly activationId?: string }, + ) => { + activationCalls.push({ args, activationId: options?.activationId }); + if (activationCalls.length === 1) signalFirstActivation(); + if (activationCalls.length === 2) signalSecondActivation(); + }, + cancel: async () => undefined, + onEvent: (listener: (event: Event) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + listSkills: async () => [ + { + name: 'foo', + description: 'foo skill', + path: '/tmp/foo.md', + source: 'user' as const, + type: 'prompt', + }, + ], + } as unknown as Session; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection( + (connection) => + new AcpServer(harness, connection, { + slashCommands: async (sdkSession) => { + const skills = await sdkSession.listSkills(); + return { + commands: skills.map((skill) => ({ + name: `skill:${skill.name}`, + description: skill.description, + })), + skillCommandMap: new Map([['skill:foo', 'foo']]), + }; + }, + }), + agentStream, + ); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await waitForAvailableCommands(collecting); + const firstPrompt = client.prompt({ + sessionId, + prompt: [textBlock('/skill:foo first')], + }); + const secondPrompt = client.prompt({ + sessionId, + prompt: [textBlock('/skill:foo second')], + }); + + await firstActivation; + expect(activationCalls).toHaveLength(1); + const firstActivationId = activationCalls[0]?.activationId; + expect(firstActivationId).toEqual(expect.any(String)); + + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 50, + origin: { + kind: 'skill_activation', + activationId: 'external-same-skill', + skillName: 'foo', + skillArgs: 'first', + trigger: 'user-slash', + }, + } as Event); + emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 50, + reason: 'cancelled', + } as Event); + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 51, + origin: { + kind: 'skill_activation', + activationId: firstActivationId, + skillName: 'foo', + skillArgs: 'first', + trigger: 'user-slash', + }, + } as Event); + emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 51, + reason: 'completed', + } as Event); + + await secondActivation; + expect(activationCalls).toHaveLength(2); + const secondActivationId = activationCalls[1]?.activationId; + expect(secondActivationId).toEqual(expect.any(String)); + expect(secondActivationId).not.toBe(firstActivationId); + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 52, + origin: { + kind: 'skill_activation', + activationId: secondActivationId, + skillName: 'foo', + skillArgs: 'second', + trigger: 'user-slash', + }, + } as Event); + emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 52, + reason: 'cancelled', + } as Event); + + await expect(firstPrompt).resolves.toEqual({ stopReason: 'end_turn' }); + await expect(secondPrompt).resolves.toEqual({ stopReason: 'cancelled' }); + }); + it('intercepts unknown slash commands locally and lets non-slash text flow to Session.prompt', async () => { const sessionId = 'sess-slash-C'; const { session, calls } = makeFakeSession(sessionId, [ diff --git a/packages/acp-adapter/test/tool-call-stream.test.ts b/packages/acp-adapter/test/tool-call-stream.test.ts index 7bf0511fe4..63ac88641f 100644 --- a/packages/acp-adapter/test/tool-call-stream.test.ts +++ b/packages/acp-adapter/test/tool-call-stream.test.ts @@ -66,11 +66,41 @@ function makeScriptedSession( script: readonly Event[], ): Session { const listeners = new Set<(event: Event) => void>(); + const emit = (event: Event, promptId: string | undefined): void => { + const correlatedEvent = + event.type === 'turn.started' && + event.origin.kind === 'user' && + event.origin.promptId === undefined + ? ({ ...event, origin: { ...event.origin, promptId } } as Event) + : event; + for (const fn of listeners) fn(correlatedEvent); + }; const session = { id: sessionId, - prompt: async (_input: unknown) => { + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { + if (!script.some((event) => event.type === 'turn.started')) { + const firstTurnEvent = script.find( + (event): event is Event & { turnId: number } => + 'turnId' in event && typeof event.turnId === 'number', + ); + if (firstTurnEvent !== undefined) { + emit( + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: firstTurnEvent.turnId, + origin: { kind: 'user', promptId: options?.promptId }, + } as Event, + options?.promptId, + ); + } + } for (const ev of script) { - for (const fn of listeners) fn(ev); + emit(ev, options?.promptId); } }, cancel: async () => undefined, @@ -181,11 +211,23 @@ describe('AcpServer tool-call streaming', () => { }); it('uses turn-prefixed toolCallId so identical SDK ids across turns do not collide', async () => { - // We script two consecutive `tool.call.started` events with the - // SAME SDK `toolCallId` but DIFFERENT `turnId` to assert the ACP - // wire ids are distinct. + // Complete an autonomous turn, then launch the correlated prompt turn + // with the SAME SDK `toolCallId`. The ACP wire ids must remain distinct + // across the legal turn boundary. const sessionId = 'sess-tc-collision'; const session = makeScriptedSession(sessionId, [ + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 1, + origin: { + kind: 'background_task', + taskId: 'task-before-prompt', + status: 'completed', + notificationId: 'task:task-before-prompt:completed', + }, + } as Event, { type: 'tool.call.started', sessionId, @@ -195,6 +237,14 @@ describe('AcpServer tool-call streaming', () => { name: 'Bash', args: { cmd: 'ls' }, } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'completed' } as Event, + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 2, + origin: { kind: 'user' }, + } as Event, { type: 'tool.call.started', sessionId, diff --git a/packages/acp-adapter/test/tool-result.test.ts b/packages/acp-adapter/test/tool-result.test.ts index a5d8fe1446..ce7917a1ec 100644 --- a/packages/acp-adapter/test/tool-result.test.ts +++ b/packages/acp-adapter/test/tool-result.test.ts @@ -64,11 +64,30 @@ function makeInMemoryStreamPair(): { function makeScriptedSession(sessionId: string, script: readonly Event[]): Session { const listeners = new Set<(event: Event) => void>(); + const emit = (event: Event): void => { + for (const fn of listeners) fn(event); + }; return { id: sessionId, - prompt: async (_input: unknown) => { + prompt: async ( + _input: unknown, + options?: { readonly promptId?: string }, + ) => { + const firstTurnEvent = script.find( + (event): event is Event & { turnId: number } => + 'turnId' in event && typeof event.turnId === 'number', + ); + if (firstTurnEvent !== undefined) { + emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: firstTurnEvent.turnId, + origin: { kind: 'user', promptId: options?.promptId }, + } as Event); + } for (const ev of script) { - for (const fn of listeners) fn(ev); + emit(ev); } }, cancel: async () => undefined, diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index f1027e72b0..bc2c785e10 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -639,6 +639,7 @@ export interface SessionStateSnapshot { readonly providerMessageId?: string; readonly origin?: /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'user'; + readonly promptId?: string; } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'skill_activation'; readonly activationId: string; @@ -715,6 +716,7 @@ export interface AgentStateSnapshot { readonly turnId: number; readonly origin: /* PromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'user'; + readonly promptId?: string; } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'skill_activation'; readonly activationId: string; @@ -839,6 +841,7 @@ export interface AgentStateSnapshot { turnId: number; origin: /* PromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'user'; + readonly promptId?: string; } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'skill_activation'; readonly activationId: string; @@ -895,6 +898,7 @@ export interface AgentStateSnapshot { readonly turnId: number; readonly origin: /* PromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* UserPromptOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'user'; + readonly promptId?: string; } | /* SkillActivationOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'skill_activation'; readonly activationId: string; @@ -1008,7 +1012,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map {}); throw error; } - await this.announceCreated({ sessionId, handle, source: 'startup' }); - return handle; } private async materializeSession(opts: MaterializeSessionOptions): Promise { @@ -249,8 +252,14 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.appendLogStore.flush(); } - private async announceCreated(event: SessionCreatedEvent): Promise { - await this.hooks.onDidCreateSession.run(event); + private async announceCreated( + event: SessionCreatedEvent, + prepare?: () => Promise, + ): Promise { + await this.hooks.onDidCreateSession.run(event, async ({ handle }) => { + await prepare?.(); + await handle.accessor.get(ISessionCronService).start(); + }); this._onDidCreateSession.fire(event); event.handle.accessor .get(ITelemetryService) @@ -297,12 +306,30 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec workDir, workspaceId: summary.workspaceId, }); - const agents = handle.accessor.get(IAgentLifecycleService); - if (agents.get(MAIN_AGENT_ID) === undefined) { - await agents.create({ agentId: MAIN_AGENT_ID }); + try { + const agents = handle.accessor.get(IAgentLifecycleService); + // Main-agent wire restore can publish task reconciliation events. Keep + // materialization inside the hook terminal so edge observers subscribe + // to onDidCreate before the wire begins restoring. + await this.announceCreated( + { sessionId, handle, source: 'resume' }, + async () => { + if (agents.get(MAIN_AGENT_ID) === undefined) { + await agents.create({ agentId: MAIN_AGENT_ID }); + } + }, + ); + return handle; + } catch (error) { + if (this.sessions.get(sessionId) === handle) { + this.sessions.delete(sessionId); + } + await this.drainAgents(handle).catch(() => {}); + try { + handle.dispose(); + } catch {} + throw error; } - await this.announceCreated({ sessionId, handle, source: 'resume' }); - return handle; } list(): readonly ISessionScopeHandle[] { diff --git a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts index 401fe93731..bed520c106 100644 --- a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts +++ b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts @@ -162,7 +162,6 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe this.tasks.set(id, task as CronTask); } await this.loadFromStore({ replace: false }); - await this.start(); await next(); }), ); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 40766015e6..9330666bd8 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; -import { Disposable } from '#/_base/di/lifecycle'; +import { Disposable, toDisposable } from '#/_base/di/lifecycle'; import { type IAgentScopeHandle, LifecycleScope, @@ -394,6 +394,24 @@ class NoopSessionExternalHooksService implements ISessionExternalHooksService { declare readonly _serviceBrand: undefined; } +let disposedSessionScopes: string[] = []; + +class RecordingSessionDisposalService + extends Disposable + implements ISessionExternalHooksService +{ + declare readonly _serviceBrand: undefined; + + constructor(@ISessionContext context: ISessionContext) { + super(); + this._register( + toDisposable(() => { + disposedSessionScopes.push(context.sessionId); + }), + ); + } +} + let recordedSessionHookEvents: string[] = []; class RecordingSessionExternalHooksService @@ -425,6 +443,7 @@ describe('SessionLifecycleService', () => { let tmpRoots: string[]; beforeEach(() => { + disposedSessionScopes = []; recordedSessionHookEvents = []; telemetryRecords = []; tmpRoots = []; @@ -474,7 +493,10 @@ describe('SessionLifecycleService', () => { stubPair(IAgentLifecycleService, agentLifecycleStub()), stubPair(ISessionMcpService, sessionMcpServiceStub()), stubPair(IConfigService, configStub()), - stubPair(ISessionCronService, { _serviceBrand: undefined } as unknown as ISessionCronService), + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => Promise.resolve(), + } as unknown as ISessionCronService), stubPair(ISessionSecondaryModelWarningService, { _serviceBrand: undefined, getSecondaryModelWarning: () => undefined, @@ -839,6 +861,134 @@ describe('SessionLifecycleService', () => { expect(captured).toMatchObject({ sessionId: 's1', handle: h, source: 'startup' }); }); + it('runs creation hooks before starting the session cron scheduler', async () => { + const order: string[] = []; + const svc = build([ + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => { + order.push('cron'); + return Promise.resolve(); + }, + } as unknown as ISessionCronService), + ]); + svc.hooks.onDidCreateSession.register('observer', async (_event, next) => { + order.push('observer'); + await next(); + }); + + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + expect(order).toEqual(['observer', 'cron']); + }); + + it('lets resume hooks observe main-agent creation before restore producers start', async () => { + const order: string[] = []; + const main = { + id: MAIN_AGENT_ID, + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected main agent service access'); + }, + }, + dispose: () => {}, + } as IAgentScopeHandle; + let liveMain: IAgentScopeHandle | undefined; + const svc = build([ + stubPair(IWorkspaceService, persistentWorkspaceStub()), + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + get: (id: string) => (id === MAIN_AGENT_ID ? liveMain : undefined), + create: () => { + order.push('main'); + liveMain = main; + return Promise.resolve(main); + }, + }), + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => { + order.push('cron'); + return Promise.resolve(); + }, + } as unknown as ISessionCronService), + ]); + svc.hooks.onDidCreateSession.register('observer', async (_event, next) => { + order.push('observer-before'); + await next(); + order.push('observer-after'); + }); + + await svc.resume('s1'); + + expect(order).toEqual(['observer-before', 'main', 'cron', 'observer-after']); + }); + + it('removes a fresh session when cron scheduler startup fails', async () => { + const startupError = new Error('cron startup failed'); + const main = { + id: MAIN_AGENT_ID, + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected main agent service access'); + }, + }, + dispose: () => {}, + } as IAgentScopeHandle; + let liveMain: IAgentScopeHandle | undefined; + const removeAgent = vi.fn((agentId: string) => { + if (liveMain?.id === agentId) liveMain = undefined; + return Promise.resolve(); + }); + const removeSessionDir = vi.fn(() => Promise.resolve()); + registerScopedService( + LifecycleScope.Session, + ISessionExternalHooksService, + RecordingSessionDisposalService, + ScopeActivation.OnScopeCreated, + 'externalHooks', + ); + const svc = build([ + stubPair(IHostFileSystem, { + remove: removeSessionDir, + } as unknown as IHostFileSystem), + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + create: () => { + liveMain = main; + return Promise.resolve(main); + }, + get: (id: string) => (id === MAIN_AGENT_ID ? liveMain : undefined), + list: () => (liveMain === undefined ? [] : [liveMain]), + remove: removeAgent, + }), + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => Promise.reject(startupError), + } as unknown as ISessionCronService), + ]); + const created: string[] = []; + svc.onDidCreateSession((event) => created.push(event.sessionId)); + + await expect( + svc.create({ + sessionId: 's1', + workDir: '/tmp/proj', + mainAgentBinding: { profile: 'agent', model: 'mock' }, + }), + ).rejects.toBe(startupError); + + expect(svc.get('s1')).toBeUndefined(); + expect(svc.list()).toEqual([]); + expect(created).toEqual([]); + expect(removeAgent).toHaveBeenCalledWith(MAIN_AGENT_ID); + expect(removeSessionDir).toHaveBeenCalledOnce(); + expect(disposedSessionScopes).toEqual(['s1']); + }); + it('emits session_started with resumed: false and the bound session id on create', async () => { const svc = build(); await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); @@ -920,6 +1070,57 @@ describe('SessionLifecycleService', () => { }); }); + it('removes a resumed session when cron scheduler startup fails', async () => { + const startupError = new Error('cron startup failed'); + const main = { + id: MAIN_AGENT_ID, + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected main agent service access'); + }, + }, + dispose: () => {}, + } as IAgentScopeHandle; + let liveMain: IAgentScopeHandle | undefined = main; + const removeAgent = vi.fn((agentId: string) => { + if (liveMain?.id === agentId) liveMain = undefined; + return Promise.resolve(); + }); + registerScopedService( + LifecycleScope.Session, + ISessionExternalHooksService, + RecordingSessionDisposalService, + ScopeActivation.OnScopeCreated, + 'externalHooks', + ); + const svc = build([ + stubPair(IWorkspaceService, persistentWorkspaceStub()), + stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + get: (id: string) => (id === MAIN_AGENT_ID ? liveMain : undefined), + list: () => (liveMain === undefined ? [] : [liveMain]), + remove: removeAgent, + }), + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => Promise.reject(startupError), + } as unknown as ISessionCronService), + ]); + + await expect(svc.resume('s1')).rejects.toBe(startupError); + + expect(svc.get('s1')).toBeUndefined(); + expect(svc.list()).toEqual([]); + expect(removeAgent).toHaveBeenCalledWith(MAIN_AGENT_ID); + expect(disposedSessionScopes).toEqual(['s1']); + expect(telemetryRecords).toContainEqual({ + event: 'session_load_failed', + properties: { sessionId: 's1', reason: 'Error' }, + }); + }); + it('runs constructor-registered session lifecycle hooks before returning create and close', async () => { registerScopedService( LifecycleScope.Session, diff --git a/packages/agent-core/src/agent/context/types.ts b/packages/agent-core/src/agent/context/types.ts index f4f6f7a4e9..02948440c2 100644 --- a/packages/agent-core/src/agent/context/types.ts +++ b/packages/agent-core/src/agent/context/types.ts @@ -6,6 +6,7 @@ import type { BackgroundTaskStatus } from '../background'; export interface UserPromptOrigin { readonly kind: 'user'; + readonly promptId?: string; } export const USER_PROMPT_ORIGIN: UserPromptOrigin = { kind: 'user' }; diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 92033add57..e60cf89b8f 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -550,7 +550,7 @@ export class Agent { get rpcMethods(): PromisableMethods { return { prompt: (payload) => { - this.turn.prompt(payload.input); + this.turn.prompt(payload.input, { kind: 'user', promptId: payload.promptId }); }, runShellCommand: (payload) => this.tools.runShellCommand(payload.command, payload.commandId), cancelShellCommand: (payload) => this.tools.cancelShellCommand(payload.commandId), diff --git a/packages/agent-core/src/agent/skill/index.ts b/packages/agent-core/src/agent/skill/index.ts index 684122fb14..a66e6e5ae9 100644 --- a/packages/agent-core/src/agent/skill/index.ts +++ b/packages/agent-core/src/agent/skill/index.ts @@ -45,7 +45,7 @@ export class SkillManager { this.recordActivation( { kind: 'skill_activation', - activationId: randomUUID(), + activationId: input.activationId ?? randomUUID(), skillName: skill.name, trigger: 'user-slash', skillType: skill.metadata.type, diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index ddc257f7d5..3971b22f3a 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -196,6 +196,7 @@ export interface SessionSummary { export interface PromptPayload { readonly input: readonly ContentPart[]; + readonly promptId?: string; /** * Client-managed session denylist, applied via * `IAgentProfileService.setSessionDisabledTools` before the prompt is @@ -312,6 +313,7 @@ export interface SkillSummary { export interface ActivateSkillPayload { readonly name: string; readonly args?: string | undefined; + readonly activationId?: string; } export interface ListWorkspaceSkillsPayload { diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 79de1337ca..24965ef4f7 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -121,6 +121,7 @@ export const skillSourceSchema = z.enum(['project', 'user', 'extra', 'builtin']) export const userPromptOriginSchema = z.object({ kind: z.literal('user'), + promptId: z.string().optional(), }) satisfies z.ZodType; export const skillActivationOriginSchema = z.object({ diff --git a/packages/klient/src/contract/agent/rpc.ts b/packages/klient/src/contract/agent/rpc.ts index 89efb07cbd..b84a931862 100644 --- a/packages/klient/src/contract/agent/rpc.ts +++ b/packages/klient/src/contract/agent/rpc.ts @@ -45,6 +45,7 @@ export const emptyPayloadSchema = z.object({}); export const promptPayloadSchema = z.object({ input: z.array(promptPartSchema), + promptId: z.string().optional(), // Mirrors `PromptPayload.disabledTools` in the engine (client-managed // session denylist, full-replace). disabledTools: z.array(z.string()).optional(), diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts index bafbce3ae6..1928ac414a 100644 --- a/packages/klient/src/core/facade/agent.ts +++ b/packages/klient/src/core/facade/agent.ts @@ -32,6 +32,7 @@ export type AgentTaskInfo = Awaited>[numbe export interface AgentFacade { prompt(input: { input: readonly ContentPart[]; + promptId?: string; disabledTools?: readonly string[]; }): Promise; steer(input: { input: readonly ContentPart[] }): Promise; diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 8197d9bac0..7b2195347a 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -9,6 +9,7 @@ import { import { Session } from '#/session'; import type { KimiAuthFacade } from '#/auth'; +import type { ApprovalHandler, Event, QuestionHandler } from '#/events'; import type { SDKRpcClientBase } from '#/rpc'; import type { AuthenticateMcpServerOptions, @@ -33,6 +34,7 @@ import type { TelemetryContextPatch, TelemetryProperties, TestMcpServerOptions, + Unsubscribe, } from '#/types'; export interface KimiHarnessRuntimeOptions { @@ -165,6 +167,38 @@ export class KimiHarness { return session; } + /** + * Subscribe to live transport events for one session id without first + * materializing a {@link Session}. This does not replay persisted history; + * the caller owns the returned subscription and must release it. + */ + onSessionEvent(sessionId: string, listener: (event: Event) => void): Unsubscribe { + const id = normalizeSessionId(sessionId); + return this.rpc.onEvent((event) => { + if (event.sessionId === id) listener(event); + }); + } + + /** + * Install an ownership-aware approval handler before a Session object + * exists. Releasing it only removes this exact handler, so a later Session + * handoff cannot be accidentally cleared. + */ + registerSessionApprovalHandler( + sessionId: string, + handler: ApprovalHandler, + ): Unsubscribe { + return this.rpc.registerApprovalHandler(normalizeSessionId(sessionId), handler); + } + + /** Ownership-aware counterpart of {@link registerSessionApprovalHandler}. */ + registerSessionQuestionHandler( + sessionId: string, + handler: QuestionHandler, + ): Unsubscribe { + return this.rpc.registerQuestionHandler(normalizeSessionId(sessionId), handler); + } + async reloadSession(input: ReloadSessionInput): Promise { const id = normalizeSessionId(input.id); const active = this.activeSessions.get(id); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 42fc747abd..be3631dcb2 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -66,6 +66,7 @@ const MAIN_AGENT_ID = 'main'; export interface SessionPromptRpcInput { readonly sessionId: string; readonly input: PromptInput; + readonly promptId?: string; /** * Client-managed session tool denylist (full-replace semantics), forwarded * to engines with profile tool gating. Omit to keep the persisted value; @@ -119,6 +120,7 @@ export type SetSessionSwarmModeRpcInput = export interface ActivateSkillRpcInput extends SessionIdRpcInput { readonly name: string; readonly args?: string | undefined; + readonly activationId?: string; } export interface ActivatePluginCommandRpcInput extends SessionIdRpcInput { @@ -133,11 +135,21 @@ export interface ReconnectMcpServerRpcInput extends SessionIdRpcInput { type ResolvedCoreAPI = RPCMethods; +interface HandlerRegistration { + readonly handler: T; +} + export abstract class SDKRpcClientBase { private readonly interactiveAgentScope = new AsyncLocalStorage(); private readonly eventListeners = new Set<(event: Event) => void>(); - private readonly approvalHandlers = new Map(); - private readonly questionHandlers = new Map(); + private readonly approvalHandlers = new Map< + string, + HandlerRegistration + >(); + private readonly questionHandlers = new Map< + string, + HandlerRegistration + >(); get interactiveAgentId(): string { return this.interactiveAgentScope.getStore() ?? MAIN_AGENT_ID; @@ -323,6 +335,7 @@ export abstract class SDKRpcClientBase { sessionId: input.sessionId, agentId, input: input.input, + promptId: input.promptId, disabledTools: input.disabledTools, }); } @@ -777,6 +790,7 @@ export abstract class SDKRpcClientBase { agentId: this.interactiveAgentId, name: input.name, args: input.args, + activationId: input.activationId, }); } @@ -809,7 +823,20 @@ export abstract class SDKRpcClientBase { this.approvalHandlers.delete(sessionId); return; } - this.approvalHandlers.set(sessionId, handler); + this.approvalHandlers.set(sessionId, { handler }); + } + + registerApprovalHandler(sessionId: string, handler: ApprovalHandler): Unsubscribe { + const registration = { handler }; + this.approvalHandlers.set(sessionId, registration); + let active = true; + return () => { + if (!active) return; + active = false; + if (this.approvalHandlers.get(sessionId) === registration) { + this.approvalHandlers.delete(sessionId); + } + }; } setQuestionHandler(sessionId: string, handler: QuestionHandler | undefined): void { @@ -817,7 +844,20 @@ export abstract class SDKRpcClientBase { this.questionHandlers.delete(sessionId); return; } - this.questionHandlers.set(sessionId, handler); + this.questionHandlers.set(sessionId, { handler }); + } + + registerQuestionHandler(sessionId: string, handler: QuestionHandler): Unsubscribe { + const registration = { handler }; + this.questionHandlers.set(sessionId, registration); + let active = true; + return () => { + if (!active) return; + active = false; + if (this.questionHandlers.get(sessionId) === registration) { + this.questionHandlers.delete(sessionId); + } + }; } clearSessionHandlers(sessionId: string): void { @@ -828,8 +868,8 @@ export abstract class SDKRpcClientBase { async requestApproval( request: ApprovalRequest & { sessionId: string; agentId: string }, ): Promise { - const handler = this.approvalHandlers.get(request.sessionId); - if (handler === undefined) { + const registration = this.approvalHandlers.get(request.sessionId); + if (registration === undefined) { return { decision: 'cancelled', feedback: 'No approval handler registered.', @@ -837,7 +877,7 @@ export abstract class SDKRpcClientBase { } try { - return await handler(request); + return await registration.handler(request); } catch (error) { this.receiveEvent({ type: 'error', @@ -855,11 +895,11 @@ export abstract class SDKRpcClientBase { async requestQuestion( request: QuestionRequest & { sessionId: string; agentId: string }, ): Promise { - const handler = this.questionHandlers.get(request.sessionId); - if (handler === undefined) return null; + const registration = this.questionHandlers.get(request.sessionId); + if (registration === undefined) return null; try { - return await handler(request); + return await registration.handler(request); } catch (error) { this.receiveEvent({ type: 'error', diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 31f5c64d10..d60d8bddfd 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -385,11 +385,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { private readonly globalMcpOAuthFlows = new Map(); /** * Per-live-session event/interaction wirings (`src/v2/session-wiring.ts`): - * created when a session materializes through this client (create / resume / - * fork / reload), dropped on close (ours or the engine's). Each wiring feeds - * the base class's event listeners from the session's per-agent event buses - * and bridges its pending approvals / questions / user-tool calls to the - * registered handlers. + * created from the session-lifecycle hook before autonomous producers start, + * then confirmed idempotently by create / resume / fork / reload. Dropped on + * close (ours or the engine's). Each wiring feeds the base class's event + * listeners from the session's per-agent event buses and bridges its pending + * approvals / questions / user-tool calls to the registered handlers. */ private readonly sessionWirings = new Map(); /** App-scope subscriptions (global event forwarding, lifecycle tracking), disposed in {@link close}. */ @@ -444,6 +444,20 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { app.accessor.get(IProviderService).ready, ]).then(() => undefined); this.appSubscriptions.push( + this.app.accessor + .get(ISessionLifecycleService) + .hooks.onDidCreateSession.register( + 'node-sdk-event-wiring', + async (event, next) => { + this.wireSession(event.handle); + try { + await next(); + } catch (error) { + this.unwireSession(event.sessionId); + throw error; + } + }, + ), // v1's stream carries `session.meta.updated` (the prompt metadata // path) — the one v1-visible fact the v2 engine publishes on the // process-global IEventService rather than a per-agent bus. Every other @@ -1477,7 +1491,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { */ override async prompt(input: SessionPromptRpcInput): Promise { const agent = await this.agentFacade(input.sessionId); - await agent.prompt({ input: input.input, disabledTools: input.disabledTools }); + await agent.prompt({ + input: input.input, + promptId: input.promptId, + disabledTools: input.disabledTools, + }); } /** @@ -1535,7 +1553,11 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { */ override async activateSkill(input: ActivateSkillRpcInput): Promise { const agent = await this.agentScope(input.sessionId); - await agent.accessor.get(IAgentSkillService).activate({ name: input.name, args: input.args }); + await agent.accessor.get(IAgentSkillService).activate({ + name: input.name, + args: input.args, + activationId: input.activationId, + }); if (this.interactiveAgentId === MAIN_AGENT_ID) { await this.updatePromptMetadata(input.sessionId, promptMetadataTextFromSkill(input)); } diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 9222112832..124573c5a7 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -98,16 +98,30 @@ export class Session { this.rpc.setApprovalHandler(this.id, handler); } + registerApprovalHandler(handler: ApprovalHandler): Unsubscribe { + this.ensureOpen(); + return this.rpc.registerApprovalHandler(this.id, handler); + } + setQuestionHandler(handler: QuestionHandler | undefined): void { this.ensureOpen(); this.rpc.setQuestionHandler(this.id, handler); } - async prompt(input: string | PromptInput): Promise { + registerQuestionHandler(handler: QuestionHandler): Unsubscribe { + this.ensureOpen(); + return this.rpc.registerQuestionHandler(this.id, handler); + } + + async prompt( + input: string | PromptInput, + options?: { readonly promptId?: string }, + ): Promise { this.ensureOpen(); await this.rpc.prompt({ sessionId: this.id, input: normalizePromptInput(input), + promptId: options?.promptId, }); } @@ -548,7 +562,11 @@ export class Session { return this.rpc.getPluginInfo(id); } - async activateSkill(name: string, args?: string | undefined): Promise { + async activateSkill( + name: string, + args?: string | undefined, + options?: { readonly activationId?: string }, + ): Promise { this.ensureOpen(); const skillName = normalizeRequiredString( name, @@ -560,6 +578,7 @@ export class Session { sessionId: this.id, name: skillName, ...(skillArgs !== undefined ? { args: skillArgs } : {}), + activationId: options?.activationId, }); } diff --git a/packages/node-sdk/src/v2/event-mapper.ts b/packages/node-sdk/src/v2/event-mapper.ts index e004e396c1..60d5cf48a6 100644 --- a/packages/node-sdk/src/v2/event-mapper.ts +++ b/packages/node-sdk/src/v2/event-mapper.ts @@ -24,9 +24,11 @@ import type { DomainEvent } from '@moonshot-ai/agent-core-v2'; * edge), `context.spliced`, `task.notified`, `plan.revision`, and the * `permission.approval.*` pair (v1 surfaces approvals through the * `requestApproval` callback, never as events). - * - `prompt.*`: the v2 prompt service publishes them on the agent bus, but in - * v1 they are synthesized by the daemon services layer onto the global - * `IEventService` — the in-process SDK client never sees them. + * - most `prompt.*`: the v2 prompt service publishes them on the agent bus, + * but in v1 they are synthesized by the daemon services layer onto the + * global `IEventService` — the in-process SDK client never sees them. + * `prompt.completed` is retained because it is the only correlated terminal + * signal when a v2 prompt is blocked or fails before a turn is launched. */ const DROPPED_DOMAIN_EVENT_TYPES: ReadonlySet = new Set([ 'agent.activity.updated', @@ -36,7 +38,6 @@ const DROPPED_DOMAIN_EVENT_TYPES: ReadonlySet = new Set([ 'permission.approval.requested', 'permission.approval.resolved', 'prompt.submitted', - 'prompt.completed', 'prompt.aborted', 'prompt.steered', ]); diff --git a/packages/node-sdk/test/create-session-transport.test.ts b/packages/node-sdk/test/create-session-transport.test.ts index ff09abf3be..fb83cc5138 100644 --- a/packages/node-sdk/test/create-session-transport.test.ts +++ b/packages/node-sdk/test/create-session-transport.test.ts @@ -11,7 +11,13 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Kaos } from '@moonshot-ai/kaos'; -import { createKimiHarness, KimiHarness } from '#/index'; +import { + createKimiHarness, + KimiHarness, + type ApprovalHandler, + type Event, + type QuestionHandler, +} from '#/index'; import type { KimiError } from '#/index'; import type { ResumeSessionInput, ResumedSessionSummary } from '#/types'; import { SDKRpcClientBase } from '#/rpc'; @@ -106,6 +112,123 @@ class StubRpc extends SDKRpcClientBase { } } +class ResumeEventRpc extends SDKRpcClientBase { + private subscriptions = 0; + + constructor(private readonly rejectResume = false) { + super(); + } + + get listenerCount(): number { + return this.subscriptions; + } + + protected async getRpc(): Promise { + throw new Error('not used'); + } + + override onEvent(listener: (event: Event) => void): () => void { + const unsubscribe = super.onEvent(listener); + this.subscriptions += 1; + let active = true; + return () => { + if (!active) return; + active = false; + this.subscriptions -= 1; + unsubscribe(); + }; + } + + override async resumeSession(input: ResumeSessionInput): Promise { + for (const event of resumeEvents(input.id)) { + this.receiveEvent(event); + } + if (this.rejectResume) { + throw new Error('resume failed'); + } + return resumedSummary(input.id); + } + + override async closeSession(): Promise {} +} + +function resumedSummary(id: string): ResumedSessionSummary { + return { + id, + workDir: '/tmp/work', + sessionDir: '/tmp/session', + createdAt: 1, + updatedAt: 1, + sessionMetadata: { + createdAt: '', + updatedAt: '', + title: '', + isCustomTitle: false, + agents: {}, + custom: {}, + }, + agents: {}, + }; +} + +function resumeEvents(sessionId: string): readonly Event[] { + return [ + { + type: 'cron.fired', + sessionId, + agentId: 'main', + origin: { + kind: 'cron_job', + jobId: 'cron-example', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + prompt: 'Review the scheduled report.', + }, + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 7, + origin: { + kind: 'cron_job', + jobId: 'cron-example', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + }, + { + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 7, + delta: 'Scheduled review finished.', + }, + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 7, + reason: 'completed', + }, + ] as Event[]; +} + +function makeStubHarness(rpc: SDKRpcClientBase): KimiHarness { + return new KimiHarness(rpc, { + homeDir: '/tmp/home', + configPath: '/tmp/config.toml', + auth: { status: async () => ({ providers: [] }) } as never, + telemetry: recordingTelemetry([]), + ensureConfigFile: async () => undefined, + onClose: () => undefined, + }); +} + describe('KimiHarness.createSession transport link', () => { it('emits session_started with client attribution when a session is opened', async () => { const homeDir = await makeTempDir(); @@ -866,6 +989,138 @@ effort = "medium" }); }); + it('filters session events before resume resolves and then stays live', async () => { + const sessionId = 'ses_resume_event_relay'; + const rpc = new ResumeEventRpc(); + const harness = makeStubHarness(rpc); + const events: Event[] = []; + const otherSessionEvents: Event[] = []; + const unsubscribe = harness.onSessionEvent(sessionId, (event) => events.push(event)); + const unsubscribeOther = harness.onSessionEvent('ses_other', (event) => { + otherSessionEvents.push(event); + }); + + await harness.resumeSession({ id: sessionId }); + rpc.receiveEvent({ + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 8, + delta: 'Live after resume.', + }); + + expect(events.map((event) => event.type)).toEqual([ + 'cron.fired', + 'turn.started', + 'assistant.delta', + 'turn.ended', + 'assistant.delta', + ]); + expect(otherSessionEvents).toEqual([]); + expect(events.filter((event) => event.type === 'cron.fired')).toHaveLength(1); + expect(rpc.listenerCount).toBe(2); + + unsubscribe(); + unsubscribeOther(); + expect(rpc.listenerCount).toBe(0); + await harness.close(); + }); + + it('keeps pre-resume subscription ownership explicit when resume rejects', async () => { + const rpc = new ResumeEventRpc(true); + const harness = makeStubHarness(rpc); + const unsubscribe = harness.onSessionEvent( + 'ses_resume_event_rejection', + () => undefined, + ); + + await expect( + harness.resumeSession({ id: 'ses_resume_event_rejection' }), + ).rejects.toThrow('resume failed'); + + expect(rpc.listenerCount).toBe(1); + unsubscribe(); + expect(rpc.listenerCount).toBe(0); + await harness.close(); + }); + + it('releases only the exact interaction handler registration owner', async () => { + const rpc = new ResumeEventRpc(); + const sessionId = 'ses_interaction_registration_owner'; + const approvalHandler: ApprovalHandler = () => ({ decision: 'approved' }); + const questionHandler: QuestionHandler = () => ({ 'Continue?': 'Yes' }); + const releaseApprovalFirst = rpc.registerApprovalHandler( + sessionId, + approvalHandler, + ); + const releaseApprovalSecond = rpc.registerApprovalHandler( + sessionId, + approvalHandler, + ); + const releaseQuestionFirst = rpc.registerQuestionHandler( + sessionId, + questionHandler, + ); + const releaseQuestionSecond = rpc.registerQuestionHandler( + sessionId, + questionHandler, + ); + + releaseApprovalFirst(); + releaseQuestionFirst(); + await expect( + rpc.requestApproval({ + sessionId, + agentId: 'main', + toolCallId: 'tool-registration-owner', + toolName: 'Bash', + action: 'run command', + display: { kind: 'command', command: 'echo ready' }, + }), + ).resolves.toEqual({ decision: 'approved' }); + await expect( + rpc.requestQuestion({ + sessionId, + agentId: 'main', + toolCallId: 'question-registration-owner', + questions: [ + { + question: 'Continue?', + options: [{ label: 'Yes' }], + }, + ], + }), + ).resolves.toEqual({ 'Continue?': 'Yes' }); + + rpc.setApprovalHandler(sessionId, approvalHandler); + rpc.setQuestionHandler(sessionId, questionHandler); + releaseApprovalSecond(); + releaseQuestionSecond(); + await expect( + rpc.requestApproval({ + sessionId, + agentId: 'main', + toolCallId: 'tool-setter-owner', + toolName: 'Bash', + action: 'run command', + display: { kind: 'command', command: 'echo ready' }, + }), + ).resolves.toEqual({ decision: 'approved' }); + await expect( + rpc.requestQuestion({ + sessionId, + agentId: 'main', + toolCallId: 'question-setter-owner', + questions: [ + { + question: 'Continue?', + options: [{ label: 'Yes' }], + }, + ], + }), + ).resolves.toEqual({ 'Continue?': 'Yes' }); + }); + it('rejects an active session resume when the requested profile differs from its binding', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 88a23c50c6..3552399c90 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -13,6 +13,14 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; +import { + IAgentLifecycleService, + IEventBus, + ISessionLifecycleService, + MAIN_AGENT_ID, + type DomainEvent, +} from '@moonshot-ai/agent-core-v2'; + import { createKimiHarnessV2, ErrorCodes, KimiError, KimiHarness, SDKRpcClientV2 } from '#/index'; import { foldAgentWireReplay } from '#/v2/resume-replay'; import { IHostRequestHeaders } from '@moonshot-ai/agent-core-v2'; @@ -144,6 +152,151 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { } }); + it('wires session events before downstream creation hooks can emit', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const sessionId = 'session_resume_hook_event'; + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + let releaseHook!: () => void; + const hookGate = new Promise((resolve) => { + releaseHook = resolve; + }); + let signalEventPublished!: () => void; + const eventPublished = new Promise((resolve) => { + signalEventPublished = resolve; + }); + const events: Array<{ readonly type: string; readonly sessionId?: string }> = []; + const unsubscribe = rpc.onEvent((event) => { + events.push(event); + }); + let resumeSettled = false; + let resume: Promise | undefined; + const lifecycle = rpc.engineAccessor.get(ISessionLifecycleService); + const hook = lifecycle.hooks.onDidCreateSession.register( + 'test-resume-hook-event', + async (event, next) => { + if (event.source === 'resume' && event.sessionId === sessionId) { + const agentLifecycle = event.handle.accessor.get(IAgentLifecycleService); + const onDidCreate = agentLifecycle.onDidCreate((main) => { + if (main.id !== MAIN_AGENT_ID) return; + main.accessor.get(IEventBus).publish({ + type: 'assistant.delta', + turnId: 1, + delta: 'Published before resume returned.', + } as DomainEvent); + signalEventPublished(); + }); + try { + // The terminal materializes the main agent. SessionEventWiring's + // earlier onDidCreate listener must attach its event bus before + // this callback publishes, and the outer hook stays pending so + // the assertion runs before resume can settle. + await next(); + await hookGate; + } finally { + onDidCreate.dispose(); + } + return; + } + await next(); + }, + ); + + try { + await rpc.createSession({ id: sessionId, workDir }); + await rpc.closeSession({ sessionId }); + events.length = 0; + + resume = rpc.resumeSession({ id: sessionId }).finally(() => { + resumeSettled = true; + }); + await eventPublished; + + expect(resumeSettled).toBe(false); + expect(events).toContainEqual({ + type: 'assistant.delta', + sessionId, + agentId: MAIN_AGENT_ID, + turnId: 1, + delta: 'Published before resume returned.', + }); + } finally { + releaseHook(); + await resume?.catch(() => undefined); + hook.dispose(); + unsubscribe(); + await rpc.close(); + } + }); + + it('drops provisional event wiring when a downstream creation hook fails', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + const sessionId = 'session_resume_hook_failure'; + const rpc = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY }); + const lifecycle = rpc.engineAccessor.get(ISessionLifecycleService); + const startupError = new Error('downstream startup failed'); + let rejectNextResume = true; + const hook = lifecycle.hooks.onDidCreateSession.register( + 'test-resume-hook-failure', + async (event, next) => { + if ( + event.source === 'resume' && + event.sessionId === sessionId && + rejectNextResume + ) { + rejectNextResume = false; + throw startupError; + } + await next(); + }, + ); + const events: Array<{ readonly type: string; readonly sessionId?: string }> = []; + const unsubscribe = rpc.onEvent((event) => { + events.push(event); + }); + + try { + await rpc.createSession({ id: sessionId, workDir }); + await rpc.closeSession({ sessionId }); + events.length = 0; + + await expect(rpc.resumeSession({ id: sessionId })).rejects.toBe(startupError); + expect(lifecycle.get(sessionId)).toBeUndefined(); + + await rpc.resumeSession({ id: sessionId }); + const resumed = lifecycle.get(sessionId); + if (resumed === undefined) throw new Error('session was not resumed'); + const main = resumed.accessor.get(IAgentLifecycleService).get(MAIN_AGENT_ID); + if (main === undefined) throw new Error('resumed session has no main agent'); + main.accessor.get(IEventBus).publish({ + type: 'assistant.delta', + turnId: 2, + delta: 'Published after retry.', + } as DomainEvent); + + expect( + events.filter((event) => event.type === 'assistant.delta'), + ).toEqual([ + { + type: 'assistant.delta', + sessionId, + agentId: MAIN_AGENT_ID, + turnId: 2, + delta: 'Published after retry.', + }, + ]); + } finally { + hook.dispose(); + unsubscribe(); + await rpc.close(); + } + }); + it('fails loudly with not_implemented for methods not yet migrated', async () => { const { harness } = await makeHarness(); try { diff --git a/packages/node-sdk/test/session-event-wiring.test.ts b/packages/node-sdk/test/session-event-wiring.test.ts index e01e2222fa..2a8322147d 100644 --- a/packages/node-sdk/test/session-event-wiring.test.ts +++ b/packages/node-sdk/test/session-event-wiring.test.ts @@ -1,10 +1,10 @@ /** * `SessionEventWiring` — the in-process v1 edge over the v2 per-agent event - * bus. Covers the status-snapshot fold: v2 emits `agent.status.updated` in - * slices and the model slice rides only the bind-time emission, so the - * wiring merges a consistent usage + context + model snapshot into every - * status event (mirrors kap-server's broadcaster bridge), including the - * secondary-model derived id resolution. + * bus. Covers correlated terminal forwarding plus the status-snapshot fold: + * v2 emits `agent.status.updated` in slices and the model slice rides only + * the bind-time emission, so the wiring merges a consistent usage + context + * + model snapshot into every status event (mirrors kap-server's broadcaster + * bridge), including the secondary-model derived id resolution. * Run: pnpm exec vitest run test/session-event-wiring.test.ts */ import { describe, expect, it } from 'vitest'; @@ -124,7 +124,34 @@ function bindStatusServices(agent: FakeAgentHandle, model: string): void { // Tests // --------------------------------------------------------------------------- -describe('SessionEventWiring status snapshot fold', () => { +describe('SessionEventWiring event translation', () => { + it('forwards a correlated prompt.completed terminal fact with session and agent stamps', () => { + const main = new FakeAgentHandle('main'); + const { sink, events } = collectingSink(); + const wiring = new SessionEventWiring(makeSession([main]), sink); + try { + main.bus.emit({ + type: 'prompt.completed', + promptId: 'prompt-before-turn', + finishedAt: '2026-01-01T00:00:00.000Z', + reason: 'blocked', + }); + } finally { + wiring.dispose(); + } + + expect(events).toEqual([ + { + type: 'prompt.completed', + promptId: 'prompt-before-turn', + finishedAt: '2026-01-01T00:00:00.000Z', + reason: 'blocked', + sessionId: 's1', + agentId: 'main', + }, + ]); + }); + it('folds a consistent usage + context + model snapshot into every status event', () => { const sub = new FakeAgentHandle('agent-1'); bindStatusServices(sub, 'sub-model'); diff --git a/packages/node-sdk/test/session-prompt-input.test.ts b/packages/node-sdk/test/session-prompt-input.test.ts index f88c710009..49db54ceb6 100644 --- a/packages/node-sdk/test/session-prompt-input.test.ts +++ b/packages/node-sdk/test/session-prompt-input.test.ts @@ -79,6 +79,24 @@ describe('Session.prompt input normalization', () => { expect(prompt).toHaveBeenCalledWith({ sessionId: 'ses_multimodal_prompt', input, + promptId: undefined, + }); + }); + + it('forwards a caller-supplied prompt correlation id', async () => { + const prompt = vi.fn(async () => {}); + const session = new Session({ + id: 'ses_correlated_prompt', + workDir: '/tmp/work', + rpc: { prompt } as unknown as SDKRpcClientBase, + }); + + await session.prompt('correlated', { promptId: 'prompt_acp_1' }); + + expect(prompt).toHaveBeenCalledWith({ + sessionId: 'ses_correlated_prompt', + input: [{ type: 'text', text: 'correlated' }], + promptId: 'prompt_acp_1', }); }); @@ -120,6 +138,7 @@ describe('Session.prompt input normalization', () => { sessionId: 'ses_scoped_agent', agentId: 'agent-btw', input: [{ type: 'text', text: 'side question' }], + promptId: undefined, }, ]); expect(rpc.enterPlanCalls).toEqual([{ sessionId: 'ses_scoped_agent', agentId: 'agent-btw' }]); diff --git a/packages/node-sdk/test/session-skills.test.ts b/packages/node-sdk/test/session-skills.test.ts index 4d6f4051a5..ffddff00c4 100644 --- a/packages/node-sdk/test/session-skills.test.ts +++ b/packages/node-sdk/test/session-skills.test.ts @@ -138,8 +138,9 @@ describe('Session skills', () => { (event) => event.type === 'session.meta.updated', ); const ended = waitForSDKEvent(session, (event) => event.type === 'turn.ended'); + const activationId = 'activation_sdk_review'; - await session.activateSkill(' review ', ' src/app.ts '); + await session.activateSkill(' review ', ' src/app.ts ', { activationId }); const activatedEvent = await activated; const metaEvent = await metaUpdated; await ended; @@ -161,6 +162,9 @@ describe('Session skills', () => { expect(events.findIndex((event) => event.type === 'turn.started')).toBeGreaterThan( events.findIndex((event) => event.type === 'skill.activated'), ); + expect(events.find((event) => event.type === 'turn.started')).toMatchObject({ + origin: { kind: 'skill_activation', activationId }, + }); expect(metaEvent).toMatchObject({ type: 'session.meta.updated', sessionId: session.id, diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index b391344b03..7c3c13f482 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -162,7 +162,7 @@ function scrubHomePrefixes(value: unknown, home: HomePair): unknown { } /** - * Understood v1↔v2 return-value gaps, pinned per method with the reason. + * Understood v1↔v2 return-value/event gaps, pinned per surface with the reason. * Each entry is a projection applied to BOTH results before comparison, so * the comparison still covers everything not listed here. Keep empty unless a * gap is genuinely accepted; remove entries as gaps close. @@ -290,6 +290,15 @@ const KNOWN_DIFFS = { // ever runs) and compares in full there. getMcpStartupMetrics: (metrics: McpStartupMetrics): unknown => Object.keys(metrics).length === 1 ? {} : metrics, + // The v2 prompt scheduler owns a correlated `prompt.completed` resource + // fact; v1 has no in-process prompt-resource service and therefore cannot + // emit the same event. The v2 SDK intentionally forwards this additive + // public fact because a blocked/failed prompt can complete before any turn + // exists. Comparisons project it out, while the event case below asserts + // its full promptId/reason payload so this richer surface cannot disappear + // silently. + eventPromptCompleted: (events: readonly Event[]): readonly Event[] => + events.filter((event) => event.type !== 'prompt.completed'), // Session export: `zipPath` is the caller-chosen output (different per // engine by construction) — deleted. `sessionDir` compares after the // home-prefix scrub (same `/sessions//` layout on @@ -2456,9 +2465,24 @@ describe('v1↔v2 agent interaction parity', () => { try { await createOnBoth(pair, { id: 'session_parity_agent_skill' }); const input = { sessionId: 'session_parity_agent_skill' } as const; + const activationId = 'activation-parity-skill'; + const v1Events: Event[] = []; + const v2Events: Event[] = []; + pair.v1.onEvent((event) => v1Events.push(event)); + pair.v2.onEvent((event) => v2Events.push(event)); await Promise.all([ - pair.v1.activateSkill({ ...input, name: 'parity-skill', args: 'some args' }), - pair.v2.activateSkill({ ...input, name: 'parity-skill', args: 'some args' }), + pair.v1.activateSkill({ + ...input, + name: 'parity-skill', + args: 'some args', + activationId, + }), + pair.v2.activateSkill({ + ...input, + name: 'parity-skill', + args: 'some args', + activationId, + }), ]); const project = KNOWN_DIFFS.listSessions; const [v1List, v2List] = await Promise.all([ @@ -2469,6 +2493,16 @@ describe('v1↔v2 agent interaction parity', () => { normalize(project(v1List, pair.v1Home), 'id'), ); expect(v1List[0]?.lastPrompt).toBe('/parity-skill some args'); + expect( + v1Events.find((event) => event.type === 'turn.started'), + ).toMatchObject({ + origin: { kind: 'skill_activation', activationId }, + }); + expect( + v2Events.find((event) => event.type === 'turn.started'), + ).toMatchObject({ + origin: { kind: 'skill_activation', activationId }, + }); // An unknown skill rejects synchronously with the same code and text. const rejection = 'Skill "missing-skill" was not found'; await expect( @@ -4010,11 +4044,20 @@ describe('v1↔v2 event & interaction parity', () => { const input = { sessionId: 'session_parity_events_prompt' } as const; const v1Events: Event[] = []; const v2Events: Event[] = []; + const promptId = 'prompt-parity-events'; pair.v1.onEvent((event) => v1Events.push(event)); pair.v2.onEvent((event) => v2Events.push(event)); await Promise.all([ - pair.v1.prompt({ ...input, input: [{ type: 'text', text: 'hello events' }] }), - pair.v2.prompt({ ...input, input: [{ type: 'text', text: 'hello events' }] }), + pair.v1.prompt({ + ...input, + input: [{ type: 'text', text: 'hello events' }], + promptId, + }), + pair.v2.prompt({ + ...input, + input: [{ type: 'text', text: 'hello events' }], + promptId, + }), ]); await settleTurns(); // Two pinned engine-internal differences in the failure path, both @@ -4025,7 +4068,10 @@ describe('v1↔v2 event & interaction parity', () => { // login-guided 'LLM not set' text; v2: 'Model not set' — the same // pinned wording family as setModel / generateAgentsMd). const projectFailure = (events: readonly Event[]): unknown[] => - projectEventStream(events, input.sessionId).flatMap((projected) => { + projectEventStream( + KNOWN_DIFFS.eventPromptCompleted(events), + input.sessionId, + ).flatMap((projected) => { const entry = projected as { type: string; code?: string }; if (entry.type === 'turn.step.interrupted') return []; if (entry.type === 'error') return { type: entry.type, code: entry.code }; @@ -4039,6 +4085,23 @@ describe('v1↔v2 event & interaction parity', () => { expect(v1Projected.map((event) => (event as { type: string }).type)).toEqual( expect.arrayContaining(['session.meta.updated', 'turn.started', 'turn.ended']), ); + expect( + v1Events.find((event) => event.type === 'turn.started'), + ).toMatchObject({ origin: { kind: 'user', promptId } }); + expect( + v2Events.find((event) => event.type === 'turn.started'), + ).toMatchObject({ origin: { kind: 'user', promptId } }); + expect(v1Events.filter((event) => event.type === 'prompt.completed')).toEqual([]); + expect(v2Events.filter((event) => event.type === 'prompt.completed')).toEqual([ + expect.objectContaining({ + type: 'prompt.completed', + sessionId: input.sessionId, + agentId: 'main', + promptId, + reason: 'failed', + finishedAt: expect.any(String), + }), + ]); } finally { await closeSessionPair(pair); restoreEnv(); diff --git a/packages/protocol/src/__tests__/events.test.ts b/packages/protocol/src/__tests__/events.test.ts index 3b7638944b..eb1e8c3e7e 100644 --- a/packages/protocol/src/__tests__/events.test.ts +++ b/packages/protocol/src/__tests__/events.test.ts @@ -120,6 +120,21 @@ describe('events / display re-exports', () => { expect(parsed.sessionId).toBe('sess_1'); }); + it('preserves an optional prompt correlation id on turn.started', () => { + const parsed = eventSchema.parse({ + type: 'turn.started', + agentId: 'main', + sessionId: 'sess_1', + turnId: 2, + origin: { kind: 'user', promptId: 'prompt_from_acp' }, + }); + + expect(parsed).toMatchObject({ + type: 'turn.started', + origin: { kind: 'user', promptId: 'prompt_from_acp' }, + }); + }); + it('validates prompt.submitted events', () => { const parsed = eventSchema.parse({ type: 'prompt.submitted', diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 6f9178d145..807bd0ec97 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -45,6 +45,7 @@ export type SkillSource = 'project' | 'user' | 'extra' | 'builtin'; export interface UserPromptOrigin { readonly kind: 'user'; + readonly promptId?: string; } export interface SkillActivationOrigin { @@ -984,6 +985,7 @@ export const skillSourceSchema = z.enum(['project', 'user', 'extra', 'builtin']) export const userPromptOriginSchema = z.object({ kind: z.literal('user'), + promptId: z.string().optional(), }) satisfies z.ZodType; export const skillActivationOriginSchema = z.object({ From e640155a262dcae96f7f720bcdf59bed9a108e0d Mon Sep 17 00:00:00 2001 From: luren Date: Thu, 30 Jul 2026 15:23:36 +0800 Subject: [PATCH 2/3] fix(acp): make session resume handoff atomic --- apps/vis/server/src/lib/blob-resolver.ts | 2 +- apps/vis/server/src/lib/context-projector.ts | 2 + .../vis/server/test/lib/blob-resolver.test.ts | 21 + .../server/test/lib/context-projector.test.ts | 24 + .../vis/web/src/components/wire/renderers.tsx | 50 ++ apps/vis/web/src/lib/analysis.ts | 8 +- apps/vis/web/test/analysis.test.ts | 25 + apps/vis/web/test/renderers.test.ts | 30 + packages/acp-adapter/src/server.ts | 261 +++++-- packages/acp-adapter/src/session.ts | 458 ++++++++++- .../test/_helpers/real-engine-rig.ts | 82 +- .../test/agent-initiated-engine.e2e.test.ts | 592 +++++++++++++- .../acp-adapter/test/e2e-happy-path.test.ts | 589 +++++++++++++- .../acp-adapter/test/session-load.test.ts | 433 +++++++++- packages/agent-core-v2/src/agent/loop/loop.ts | 12 +- .../src/agent/loop/loopService.ts | 62 +- .../app/sessionLifecycle/sessionLifecycle.ts | 8 +- .../sessionLifecycleService.ts | 40 +- .../test/agent/loop/loop.test.ts | 94 +++ .../agent-core-v2/test/agent/loop/stubs.ts | 1 + .../toolSelect/toolSelectService.test.ts | 4 + .../externalHooksRunner/integration.test.ts | 1 + .../app/sessionExport/sessionExport.test.ts | 1 + .../sessionLifecycle/sessionLifecycle.test.ts | 138 ++++ .../agent-core/src/agent/context/index.ts | 19 +- .../agent-core/src/agent/records/blobref.ts | 4 +- .../agent-core/src/agent/records/index.ts | 16 +- .../agent-core/src/agent/records/types.ts | 25 +- packages/agent-core/src/agent/turn/index.ts | 257 +++++- packages/agent-core/src/rpc/client.ts | 2 +- packages/agent-core/src/rpc/core-impl.ts | 102 ++- packages/agent-core/src/rpc/sdk-api.ts | 12 + packages/agent-core/src/rpc/types.ts | 2 +- .../services/coreProcess/coreProcessClient.ts | 18 +- packages/agent-core/src/session/index.ts | 39 + .../src/session/store/session-store.ts | 12 +- packages/agent-core/src/utils/types.ts | 4 +- .../test/agent/records/blobref.test.ts | 18 + packages/agent-core/test/agent/resume.test.ts | 124 +++ packages/agent-core/test/agent/turn.test.ts | 292 ++++++- .../agent-core/test/harness/runtime.test.ts | 465 ++++++++++- packages/node-sdk/src/kimi-harness.ts | 123 +++ packages/node-sdk/src/rpc.ts | 106 ++- packages/node-sdk/src/sdk-rpc-client-v2.ts | 179 ++++- packages/node-sdk/src/sdk-rpc-client.ts | 57 +- packages/node-sdk/src/session.ts | 22 +- .../test/create-session-transport.test.ts | 451 ++++++++++- .../node-sdk/test/sdk-rpc-client-v2.test.ts | 737 +++++++++++++++++- packages/node-sdk/test/v1-v2-parity.test.ts | 7 + 49 files changed, 5758 insertions(+), 273 deletions(-) create mode 100644 apps/vis/web/test/renderers.test.ts diff --git a/apps/vis/server/src/lib/blob-resolver.ts b/apps/vis/server/src/lib/blob-resolver.ts index dbb5ba6e31..339704248c 100644 --- a/apps/vis/server/src/lib/blob-resolver.ts +++ b/apps/vis/server/src/lib/blob-resolver.ts @@ -46,7 +46,7 @@ function rehydrateRecord( baseUrl: string, ): void { const type = record['type']; - if (type === 'turn.prompt' || type === 'turn.steer') { + if (type === 'turn.prompt' || type === 'turn.steer' || type === 'turn.defer') { rehydrateParts(record['input'] as unknown as ContentPart[], sessionId, agentId, baseUrl); return; } diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index 76cdec4587..21549d3de0 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -505,6 +505,8 @@ export function projectContext( case 'forked': case 'turn.prompt': case 'turn.steer': + case 'turn.defer': + case 'turn.defer.consume': case 'turn.cancel': case 'permission.record_approval_result': case 'full_compaction.begin': diff --git a/apps/vis/server/test/lib/blob-resolver.test.ts b/apps/vis/server/test/lib/blob-resolver.test.ts index bf29441c14..a2eba5c960 100644 --- a/apps/vis/server/test/lib/blob-resolver.test.ts +++ b/apps/vis/server/test/lib/blob-resolver.test.ts @@ -130,6 +130,27 @@ describe('blob-resolver', () => { ); }); + it('resolves media held in a deferred turn input', () => { + const data: Record = { + type: 'turn.defer', + id: 'deferred-1', + input: [ + { + type: 'image_url', + imageUrl: { url: 'blobref:image/png;hashD' }, + }, + ], + origin: { kind: 'cron' }, + }; + const entries = [{ lineNo: 1, data: data as any, raw: {} }]; + + rehydrateWireEntries(entries, 'sess-4', 'main'); + + expect((entries[0]!.data as any).input[0].imageUrl.url).toBe( + '/api/sessions/sess-4/blobs/hashD?agent=main&mime=image%2Fpng', + ); + }); + it('ignores records without media URLs', () => { const data = { type: 'config.update', cwd: '/tmp' }; const entries = [{ lineNo: 1, data: data as any, raw: {} }]; diff --git a/apps/vis/server/test/lib/context-projector.test.ts b/apps/vis/server/test/lib/context-projector.test.ts index 1268753aac..0b5d9f1709 100644 --- a/apps/vis/server/test/lib/context-projector.test.ts +++ b/apps/vis/server/test/lib/context-projector.test.ts @@ -939,4 +939,28 @@ describe('context-projector', () => { expect(proj.messages[0]!.message.content[0]).toMatchObject({ text: bigText }); expect(proj.messages[1]!.message.content[0]).toMatchObject({ text: bigText }); }); + + it('does not project deferred turn bookkeeping before it enters context', () => { + const entries = [ + { + lineNo: 1, + data: { + type: 'turn.defer' as const, + id: 'deferred-1', + input: [{ type: 'text' as const, text: 'pending' }], + origin: { kind: 'cron' as const }, + }, + raw: {}, + }, + { + lineNo: 2, + data: { type: 'turn.defer.consume' as const, id: 'deferred-1' }, + raw: {}, + }, + ]; + + const proj = projectContext(entries as any); + + expect(proj.messages).toEqual([]); + }); }); diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index 126ac3bded..eabaf5b1b7 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -157,6 +157,56 @@ export const WIRE_RENDERERS: RendererMap = { ), }, + 'turn.defer': { + tone: 'warning', + label: 'defer', + headline: (r) => { + const text = firstText(r.input); + return { + main: ( + + + {r.origin.kind} + + pending → {truncate(text, 80)} + + ), + right: #{r.id.slice(0, 8)}, + }; + }, + detail: (r) => ( +
+
+ + {r.id} + + + + +
+
+
+ input ({r.input.length} part{r.input.length === 1 ? '' : 's'}) +
+
+ {r.input.map((part, i) => ( + + ))} +
+
+
+ ), + }, + + 'turn.defer.consume': { + tone: 'lifecycle', + label: 'defer·consume', + headline: (r) => ({ + main: deferred input entered context, + right: #{r.id.slice(0, 8)}, + }), + }, + 'turn.cancel': { tone: 'warning', label: 'cancel', diff --git a/apps/vis/web/src/lib/analysis.ts b/apps/vis/web/src/lib/analysis.ts index 24a83e7ae3..003392d200 100644 --- a/apps/vis/web/src/lib/analysis.ts +++ b/apps/vis/web/src/lib/analysis.ts @@ -261,7 +261,13 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis { gapMs: t - prevTime, // A gap straddling a turn boundary is "waiting for the user"; a gap // inside a turn is the agent/tool being slow. - kind: rec.type === 'turn.prompt' || rec.type === 'turn.steer' ? 'between_turns' : 'in_turn', + kind: + rec.type === 'turn.prompt' || + rec.type === 'turn.steer' || + rec.type === 'turn.defer' || + rec.type === 'turn.defer.consume' + ? 'between_turns' + : 'in_turn', }); } prevTime = t; diff --git a/apps/vis/web/test/analysis.test.ts b/apps/vis/web/test/analysis.test.ts index f8986674d5..10ca192adc 100644 --- a/apps/vis/web/test/analysis.test.ts +++ b/apps/vis/web/test/analysis.test.ts @@ -90,6 +90,31 @@ describe('analyzeWire', () => { expect(a.cache.hitRate).toBeNull(); }); + it('keeps deferred input bookkeeping outside turns and classifies its wait at the turn boundary', () => { + line = 0; + const a = analyzeWire([ + e({ type: 'turn.prompt', input: [{ type: 'text', text: 'first' }], origin: { kind: 'user' } }, 0), + loop({ type: 'step.begin', uuid: 's1', turnId: 'T1', step: 0 }, 1), + loop({ type: 'step.end', uuid: 's1', turnId: 'T1', step: 0, finishReason: 'end_turn' }, 2), + e({ + type: 'turn.defer', + id: 'deferred-1', + input: [{ type: 'text', text: 'pending' }], + origin: { kind: 'cron' }, + }, 10_000), + e({ type: 'turn.defer.consume', id: 'deferred-1' }, 10_001), + ]); + + expect(a.turns).toHaveLength(1); + expect(a.idleGaps).toEqual([ + expect.objectContaining({ + beforeLineNo: 4, + gapMs: 9998, + kind: 'between_turns', + }), + ]); + }); + it('computes cache hit rate from summed input usage', () => { line = 0; const a = analyzeWire([ diff --git a/apps/vis/web/test/renderers.test.ts b/apps/vis/web/test/renderers.test.ts new file mode 100644 index 0000000000..58a018ef70 --- /dev/null +++ b/apps/vis/web/test/renderers.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { rendererFor } from '../src/components/wire/renderers'; + +describe('wire renderers', () => { + it('renders both states of deferred turn bookkeeping', () => { + const deferred = rendererFor('turn.defer'); + const consumed = rendererFor('turn.defer.consume'); + + expect(deferred).toMatchObject({ tone: 'warning', label: 'defer' }); + expect(consumed).toMatchObject({ + tone: 'lifecycle', + label: 'defer·consume', + }); + expect( + deferred?.headline({ + type: 'turn.defer', + id: 'deferred-1', + input: [{ type: 'text', text: 'pending' }], + origin: { kind: 'cron' }, + }).main, + ).toBeDefined(); + expect( + consumed?.headline({ + type: 'turn.defer.consume', + id: 'deferred-1', + }).main, + ).toBeDefined(); + }); +}); diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index 068b834905..11ff3cb750 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -221,6 +221,14 @@ function effortStringOrUndefined(effort: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +function isAutonomousTriggerEvent(event: Event): boolean { + return ( + event.type === 'cron.fired' || + event.type === 'background.task.terminated' || + event.type === 'task.terminated' + ); +} + /** * Agent-side ACP handler. Routes `initialize` + `session/new` + `session/cancel` * into {@link KimiHarness}; refuses methods that are not yet wired with a @@ -489,28 +497,20 @@ export class AcpServer implements Agent { * `currentModeId` is always `default` on load because the SDK does * not persist mode across runs (PLAN D9). * - * The non-trivial setup (auth gate, connection guard, harness - * resume, AcpSession construction, session registration, configOptions - * computation) is shared with {@link resumeSession} via - * {@link setupSessionFromExisting}; the ONE differentiator is that - * `loadSession` calls `replayHistory()` here, whereas `resumeSession` - * deliberately skips it (per ACP spec G4 / plan gap-4.3). - */ + * The non-trivial setup (auth gate, connection guard, atomic SDK + * snapshot handoff, AcpSession construction, history/live ordering, + * session registration, and configOptions computation) is shared with + * {@link resumeSession} via {@link setupSessionFromExisting}. + */ async loadSession(params: LoadSessionRequest): Promise { const sessionId = requireCanonicalSessionId(params.sessionId); return this.withSessionSetupLock(sessionId, async () => { - const { session, acpSession, configOptions } = await this.setupSessionFromExisting({ + const { session, configOptions } = await this.setupSessionFromExisting({ cwd: params.cwd, sessionId, mcpServers: params.mcpServers, mode: 'load', }); - // Synchronously replay history — the response must not settle - // until every historical `session/update` has been pushed, - // otherwise the client would race the load completion against - // its own UI bootstrap. This is the ONE difference vs. - // `resumeSession`, which intentionally omits this step. - await acpSession.replayHistory(); this.scheduleAvailableCommandsUpdate(session.id); return { configOptions }; }); @@ -534,7 +534,7 @@ export class AcpServer implements Agent { * (a) telemetry mode is `'resume'` (vs `'load'`), and (b) no * `replayHistory()` call. See plan G4 (lines 106-170) for the * rationale, and gap-4.1 for the matching capability advertisement. - */ + */ async resumeSession(params: ResumeSessionRequest): Promise { const sessionId = requireCanonicalSessionId(params.sessionId); return this.withSessionSetupLock(sessionId, async () => { @@ -558,12 +558,11 @@ export class AcpServer implements Agent { * the unified `configOptions:` surface (PLAN D11) that both handlers * return. * - * Behavior is byte-for-byte identical to the pre-refactor - * `loadSession` body minus the `replayHistory()` call — which lives - * in `loadSession` itself because `resumeSession` per ACP spec must - * NOT replay history (the client is expected to have already seen - * those turns; replay is a load-only behavior). See plan G4 - * (lines 106-170) for the rationale. + * For `load`, the SDK freezes the resume snapshot while autonomous + * turn starts are gated. The callback installs the permanent event + * bridge, replays that snapshot, and drains events captured after the + * cut before releasing the producer. `resume` installs and drains the + * same bridge without replaying persisted history. * * The `@ts-expect-error` boundary at the SDK `resumeSession` call * is preserved verbatim — `mcpServers` is a kernel-only extension @@ -587,7 +586,8 @@ export class AcpServer implements Agent { throw RequestError.authRequired(); } this.assertNotDisposed(); - if (!this.conn) { + const conn = this.conn; + if (!conn) { throw RequestError.internalError(undefined, 'AcpServer is missing its AgentSideConnection'); } // ACP `cwd` → SDK `workDir` for parity with `newSession`. The @@ -609,10 +609,7 @@ export class AcpServer implements Agent { params.sessionId, ); let releaseInitialSessionEvents = (): void => undefined; - if ( - params.mode === 'resume' && - typeof this.harness.onSessionEvent === 'function' - ) { + if (typeof this.harness.onSessionEvent === 'function') { try { const unsubscribe = this.harness.onSessionEvent(params.sessionId, (event) => { initialSessionEvents.push(event); @@ -630,43 +627,37 @@ export class AcpServer implements Agent { throw error; } } - let session: Session; - try { - session = await this.harness.resumeSession({ - id: params.sessionId, - kaos: acpKaos, - persistenceKaos, - sessionStartedProperties: { mode: params.mode }, - // @ts-expect-error — see block comment above; mcpServers is a - // kernel-only field that the SDK forwards via spread. - mcpServers, - }); - this.assertNotDisposed(); - } catch (err) { - releaseInitialSessionEvents(); - settleInitialInteractions(); - // Surface unknown-session as invalid_params so the JSON-RPC layer - // returns a structured failure rather than a generic internal - // error. Other errors propagate as-is. - const code = (err as { code?: string } | undefined)?.code; - if (code === 'session.not_found') { - throw RequestError.invalidParams( - { sessionId: params.sessionId }, - `Unknown sessionId: ${params.sessionId}`, - ); - } - throw err; - } + const resumeInput = { + id: params.sessionId, + kaos: acpKaos, + persistenceKaos, + sessionStartedProperties: { mode: params.mode }, + // This remains a kernel-only field. Keeping the value on a named object + // preserves the SDK's intentional spread-passthrough boundary. + mcpServers, + }; + let session: Session | undefined; let createdAcpSession: AcpSession | undefined; + let pausedAcpSession: AcpSession | undefined; + let pauseAtSnapshot: Promise | undefined; + let pausedSnapshotEventCount: number | undefined; + let initialSnapshotEventCount: number | undefined; + let initialRetainedSnapshotEventCount: number | undefined; + type SetupResult = { + session: Session; + acpSession: AcpSession; + configOptions: SessionConfigOption[]; + }; + let setupResult: SetupResult | undefined; - try { + const finishSetup = async (resumedSession: Session): Promise => { + session = resumedSession; + this.assertNotDisposed(); // A cold ACP resume can race events emitted while the SDK is still // materializing its Session object. Its temporary harness subscription // captures only live events for this known id. Transfer that FIFO into - // AcpSession synchronously, before config lookup. Load deliberately does - // not use this relay: history replay needs a producer-level atomic - // snapshot/live boundary rather than a consumer-side guessed cut. - const resumeState = session.getResumeState?.(); + // AcpSession synchronously, before config lookup. + const resumeState = resumedSession.getResumeState?.(); const resumedModelAlias = resumeState?.agents?.['main']?.config?.modelAlias; const initialModelId = typeof resumedModelAlias === 'string' && resumedModelAlias.length > 0 @@ -674,45 +665,52 @@ export class AcpServer implements Agent { : undefined; const resumedThinkingEffort = resumeState?.agents?.['main']?.config?.thinkingEffort; const initialThinkingEffort = effortStringOrUndefined(resumedThinkingEffort); - const existingAcpSession = this.sessions.get(session.id); + const existingAcpSession = this.sessions.get(resumedSession.id); let acpSession: AcpSession; - if (existingAcpSession !== undefined) { + if (existingAcpSession?.session === resumedSession) { + releaseInitialSessionEvents(); + initialSessionEvents.splice(0); + acpSession = existingAcpSession; + if (params.mode === 'load') { + pausedAcpSession = acpSession; + pauseAtSnapshot ??= acpSession.pauseSessionEvents(); + await pauseAtSnapshot; + } + } else if (existingAcpSession !== undefined && params.mode === 'resume') { // An existing adapter already owns an SDK event subscription for this // session id, even if the harness returns a replacement Session // object. It observed the same transport events as the temporary raw // listener, so never replay that parallel copy. releaseInitialSessionEvents(); initialSessionEvents.splice(0); - if (existingAcpSession.session === session) { - acpSession = existingAcpSession; - } else { - existingAcpSession.dispose(); - acpSession = new AcpSession( - this.conn, - session, - this.clientCapabilities, - this.makeTelemetryTrack(), - initialModelId, - this.harness, - initialThinkingEffort, - ); - this.sessions.set(session.id, acpSession); - createdAcpSession = acpSession; - } + existingAcpSession.dispose(); + acpSession = new AcpSession( + conn, + resumedSession, + this.clientCapabilities, + this.makeTelemetryTrack(), + initialModelId, + this.harness, + initialThinkingEffort, + ); + this.sessions.set(resumedSession.id, acpSession); + createdAcpSession = acpSession; } else { + existingAcpSession?.dispose(); releaseInitialSessionEvents(); acpSession = new AcpSession( - this.conn, - session, + conn, + resumedSession, this.clientCapabilities, this.makeTelemetryTrack(), initialModelId, this.harness, initialThinkingEffort, initialSessionEvents, + true, ); initialSessionEvents.splice(0); - this.sessions.set(session.id, acpSession); + this.sessions.set(resumedSession.id, acpSession); createdAcpSession = acpSession; } @@ -732,7 +730,7 @@ export class AcpServer implements Agent { // then the harness-level default, when the resume state lacks the // field. const currentThinkingEffort = await this.resolveCurrentThinkingEffort( - session, + resumedSession, resumedThinkingEffort, ); this.assertNotDisposed(); @@ -744,8 +742,90 @@ export class AcpServer implements Agent { DEFAULT_MODE_ID, ); this.assertNotDisposed(); + if (params.mode === 'load') { + if ( + acpSession === pausedAcpSession && + pausedSnapshotEventCount !== undefined + ) { + acpSession.reconcilePausedSessionEvents( + pausedSnapshotEventCount, + isAutonomousTriggerEvent, + ); + } else if (initialRetainedSnapshotEventCount !== undefined) { + acpSession.setPausedSessionEventSnapshotBoundary( + initialRetainedSnapshotEventCount, + ); + } + await acpSession.replayHistory(); + } else { + await acpSession.flushInitialSessionEvents(); + } + this.assertNotDisposed(); settleInitialInteractions(getAcpSessionInteractionHandlers(acpSession)); - return { session, acpSession, configOptions }; + return { session: resumedSession, acpSession, configOptions }; + }; + + try { + if ( + params.mode === 'load' && + typeof this.harness.resumeSessionWithHandoff === 'function' + ) { + await this.harness.resumeSessionWithHandoff( + resumeInput, + async (resumedSession) => { + // The frozen context reconstructs turn/delta/tool state. Keep + // pre-snapshot producer triggers as candidates until history replay + // confirms which safe trigger projections succeeded; AcpSession + // removes only those matching duplicates before draining this FIFO. + pausedSnapshotEventCount ??= + pausedAcpSession?.pausedSessionEventCount(); + initialSnapshotEventCount ??= initialSessionEvents.length; + const initialBoundary = Math.min( + Math.max(initialSnapshotEventCount, 0), + initialSessionEvents.length, + ); + const transientEvents = initialSessionEvents + .slice(0, initialBoundary) + .filter(isAutonomousTriggerEvent); + initialRetainedSnapshotEventCount = transientEvents.length; + const postSnapshotEvents = + initialSessionEvents.slice(initialBoundary); + initialSessionEvents.splice( + 0, + initialSessionEvents.length, + ...transientEvents, + ...postSnapshotEvents, + ); + setupResult = await finishSetup(resumedSession); + }, + () => { + // The engine owns the exact admission cut. Events already observed + // precede this load; later producer triggers must cross the FIFO, + // while a settling active turn is reconciled against the frozen + // snapshot in the handoff callback above. + pausedAcpSession = this.sessions.get(params.sessionId); + pauseAtSnapshot = pausedAcpSession?.pauseSessionEvents(); + initialSessionEvents.splice(0); + }, + () => { + // This callback runs inside the SDK-side ordered event marker: + // every event represented by the frozen snapshot has arrived, + // while later transport events are still beyond this boundary. + pausedSnapshotEventCount = + pausedAcpSession?.pausedSessionEventCount(); + initialSnapshotEventCount = initialSessionEvents.length; + }, + ); + } else { + setupResult = await finishSetup(await this.harness.resumeSession(resumeInput)); + } + if (setupResult === undefined) { + throw RequestError.internalError( + { sessionId: params.sessionId }, + 'SDK session resume completed without an ACP handoff', + ); + } + return setupResult; } catch (error) { releaseInitialSessionEvents(); initialSessionEvents.splice(0); @@ -755,12 +835,31 @@ export class AcpServer implements Agent { // request and remains responsible for that session's updates. if ( createdAcpSession !== undefined && + session !== undefined && this.sessions.get(session.id) === createdAcpSession ) { this.sessions.delete(session.id); createdAcpSession.dispose(); } settleInitialInteractions(); + if (pausedAcpSession !== undefined) { + try { + await pausedAcpSession.flushInitialSessionEvents(); + } catch (flushError) { + log.warn('acp: failed to restore live events after load setup failed', { + sessionId: params.sessionId, + error: + flushError instanceof Error ? flushError.message : String(flushError), + }); + } + } + const code = (error as { code?: string } | undefined)?.code; + if (code === 'session.not_found') { + throw RequestError.invalidParams( + { sessionId: params.sessionId }, + `Unknown sessionId: ${params.sessionId}`, + ); + } throw error; } } diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 35f93d70c7..de279bcf1b 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -223,6 +223,9 @@ export class AcpSession { private unsubscribeApprovalHandler: (() => void) | undefined; private unsubscribeQuestionHandler: (() => void) | undefined; private queuedSessionEvents: Event[] | undefined; + private snapshotQueuedSessionEventCount = 0; + private drainingSessionEvents: Promise | undefined; + private sessionEventTail: Promise = Promise.resolve(); private disposed = false; /** @@ -282,11 +285,18 @@ export class AcpSession { initialThinkingEffort?: string, /** * Live events captured for a known session id while a cold - * `session/resume` was materializing its SDK Session. The server + * `session/load` or `session/resume` was materializing its SDK Session. + * The server * transfers ownership of this FIFO synchronously, after releasing * the temporary raw subscription and before any asynchronous setup. */ initialSessionEvents: readonly Event[] = [], + /** + * Keep the session event FIFO paused until setup establishes the ACP + * history/live boundary. Load drains after history replay; resume drains + * after its asynchronous configuration setup. + */ + deferInitialSessionEvents = false, ) { this.currentModelIdInternal = initialModelId ?? ''; this.currentThinkingEffortInternal = initialThinkingEffort ?? 'off'; @@ -328,9 +338,11 @@ export class AcpSession { queue.push(event); return; } - this.handleSessionEvent(event); + void this.enqueueSessionEvent(event); }); - this.drainQueuedSessionEvents(); + if (!deferInitialSessionEvents) { + void this.flushInitialSessionEvents(); + } interactionHandlersBySession.set(this, { approval: approvalHandler, question: questionHandler, @@ -434,6 +446,7 @@ export class AcpSession { this.unsubscribeQuestionHandler = undefined; this.queuedSessionEvents?.splice(0); this.queuedSessionEvents = undefined; + this.snapshotQueuedSessionEventCount = 0; for (const release of releases) { try { release?.(); @@ -447,7 +460,7 @@ export class AcpSession { } /** - * Drain the resume handoff FIFO after the Session listener is installed. + * Drain the load/resume handoff FIFO after the Session listener is installed. * * The queue remains active while it is being drained, so a synchronous * reentrant SDK event is appended and processed after all earlier events. @@ -455,14 +468,91 @@ export class AcpSession { * unsubscribe and constructor call, which gives the server a no-gap * handoff without changing the multicast semantics of Session.onEvent. */ - private drainQueuedSessionEvents(): void { + async flushInitialSessionEvents(): Promise { + if (this.queuedSessionEvents === undefined) return; + this.drainingSessionEvents ??= this.drainQueuedSessionEvents().finally(() => { + this.drainingSessionEvents = undefined; + }); + await this.drainingSessionEvents; + } + + /** @internal Pause live projection after all already-dispatched updates settle. */ + async pauseSessionEvents(): Promise { + if (this.disposed) { + throw new Error('Cannot pause events for a disposed ACP session'); + } + this.queuedSessionEvents ??= []; + await this.sessionEventTail; + } + + /** + * @internal Reconcile the paused live FIFO against a frozen resume snapshot. + * + * Delta/tool/turn events from the turn that settled into the snapshot would + * otherwise be projected twice when history is replayed. Candidate + * autonomous trigger events are retained until replay confirms which ones + * received a persisted display-safe projection; matching pre-snapshot events + * are then removed. Resetting projection bookkeeping makes the replay the + * sole source of pre-cut tool/turn state. + */ + pausedSessionEventCount(): number { + return this.queuedSessionEvents?.length ?? 0; + } + + reconcilePausedSessionEvents( + snapshotEventCount: number, + predicate: (event: Event) => boolean, + ): void { const queue = this.queuedSessionEvents; if (queue === undefined) return; - for (let index = 0; !this.disposed && index < queue.length; index += 1) { - this.handleSessionEvent(queue[index]!); + const boundary = Math.min(Math.max(snapshotEventCount, 0), queue.length); + const retainedSnapshotEvents = queue.slice(0, boundary).filter(predicate); + const postSnapshotEvents = queue.slice(boundary); + queue.splice(0, queue.length, ...retainedSnapshotEvents, ...postSnapshotEvents); + this.snapshotQueuedSessionEventCount = retainedSnapshotEvents.length; + this.argsByToolCall.clear(); + this.startedToolCalls.clear(); + this.currentTurnId = undefined; + } + + /** + * @internal Mark the prefix captured before the frozen resume snapshot. + * + * A newly-created ACP bridge receives an already-reconciled queue from the + * server, so it cannot infer this boundary itself. Events appended after this + * call remain live suffix events and are never removed as replay duplicates. + */ + setPausedSessionEventSnapshotBoundary(snapshotEventCount: number): void { + const queue = this.queuedSessionEvents; + this.snapshotQueuedSessionEventCount = + queue === undefined + ? 0 + : Math.min(Math.max(snapshotEventCount, 0), queue.length); + } + + private async drainQueuedSessionEvents(): Promise { + const queue = this.queuedSessionEvents; + if (queue === undefined) return; + while (!this.disposed && queue.length > 0) { + await this.enqueueSessionEvent(queue.shift()!); + } + if (this.disposed) queue.splice(0); + if (this.queuedSessionEvents === queue) { + this.queuedSessionEvents = undefined; + this.snapshotQueuedSessionEventCount = 0; } - queue.splice(0); - this.queuedSessionEvents = undefined; + } + + private enqueueSessionEvent(event: Event): Promise { + const delivery = this.sessionEventTail.then(() => this.handleSessionEvent(event)); + this.sessionEventTail = delivery.catch((error) => { + log.warn('acp: failed to project session event', { + sessionId: this.id, + eventType: event.type, + error: error instanceof Error ? error.message : String(error), + }); + }); + return this.sessionEventTail; } /** @@ -781,18 +871,31 @@ export class AcpSession { * * Errors thrown by individual `sessionUpdate` calls are caught and * logged so a single transient push failure does not truncate the - * whole replay. The method awaits every push (unlike the live - * {@link handleSessionEvent} fire-and-forget path) because replay is a one-shot + * whole replay. The method awaits every push because replay is a one-shot * batch — completion ordering is what tells the caller (`loadSession`) - * that the response is safe to return. + * that the response is safe to return. Events captured after the resume + * snapshot are flushed only after the historical batch, preserving the + * history/live boundary. */ async replayHistory(agentId: string = MAIN_AGENT_ID): Promise { + try { + const replayedAutonomousTriggers = await this.replayHistorySnapshot(agentId); + this.removeReplayedAutonomousTriggers(replayedAutonomousTriggers); + } finally { + await this.flushInitialSessionEvents(); + } + } + + private async replayHistorySnapshot( + agentId: string, + ): Promise> { + const replayedAutonomousTriggers = new Map(); const sessionId = this.id; const conn = this.conn; const resumeState = this.session.getResumeState?.(); if (!resumeState) { log.warn('acp: replayHistory called on session without resume state', { sessionId }); - return; + return replayedAutonomousTriggers; } const agent = resumeState.agents?.[agentId]; if (!agent) { @@ -801,7 +904,7 @@ export class AcpSession { agentId, knownAgents: resumeState.agents ? Object.keys(resumeState.agents) : [], }); - return; + return replayedAutonomousTriggers; } let turnId = 0; @@ -809,10 +912,13 @@ export class AcpSession { // the assistant message that issued the call is replayed and read // when the tool result lands. Lives for the duration of one replay. const toolCallTurnIds = new Map(); + const backgroundTasks = new Map( + (agent.background ?? []).map((task) => [task.taskId, task] as const), + ); for (const message of agent.context.history) { try { - await this.replayMessage(message, sessionId, conn, { + const autonomousTriggerKey = await this.replayMessage(message, sessionId, conn, { getTurnId: () => turnId, beginAssistantTurn: () => { turnId += 1; @@ -821,15 +927,94 @@ export class AcpSession { toolCallTurnIds.set(toolCallId, turnId); }, lookupToolCallTurnId: (toolCallId) => toolCallTurnIds.get(toolCallId), + backgroundTasks, }); - } catch (err) { + if (autonomousTriggerKey !== undefined) { + replayedAutonomousTriggers.set( + autonomousTriggerKey, + (replayedAutonomousTriggers.get(autonomousTriggerKey) ?? 0) + 1, + ); + } + } catch (error) { + const queuedFallbackKey = + message.role === 'user' + ? persistedAutonomousTrigger(message, backgroundTasks)?.key + : undefined; + const usedQueuedTriggerFallback = + queuedFallbackKey === undefined + ? false + : await this.replayQueuedAutonomousTriggerFallback(queuedFallbackKey); log.warn('acp: replayHistory failed to emit a message; continuing', { sessionId, role: message.role, - error: err instanceof Error ? err.message : String(err), + usedQueuedTriggerFallback, + error: error instanceof Error ? error.message : String(error), }); } } + return replayedAutonomousTriggers; + } + + private removeReplayedAutonomousTriggers( + replayed: ReadonlyMap, + ): void { + const queue = this.queuedSessionEvents; + if ( + queue === undefined || + this.snapshotQueuedSessionEventCount === 0 || + replayed.size === 0 + ) { + return; + } + const boundary = Math.min(this.snapshotQueuedSessionEventCount, queue.length); + const remaining = new Map(replayed); + const retainedSnapshotEvents = queue.slice(0, boundary).filter((event) => { + const key = autonomousTriggerEventKey(event); + if (key === undefined) return true; + const count = remaining.get(key) ?? 0; + if (count === 0) return true; + if (count === 1) { + remaining.delete(key); + } else { + remaining.set(key, count - 1); + } + return false; + }); + const postSnapshotEvents = queue.slice(boundary); + queue.splice(0, queue.length, ...retainedSnapshotEvents, ...postSnapshotEvents); + this.snapshotQueuedSessionEventCount = retainedSnapshotEvents.length; + } + + /** + * Deliver the matching pre-snapshot live trigger immediately when its + * display-safe historical projection failed. + * + * Waiting for the ordinary final FIFO drain would put the initiating user + * chunk after the persisted assistant/tool messages that belong to it. Only + * the snapshot prefix is eligible: an identical event appended after the cut + * is independent live work and must remain queued. + */ + private async replayQueuedAutonomousTriggerFallback(key: string): Promise { + const queue = this.queuedSessionEvents; + if (queue === undefined || this.snapshotQueuedSessionEventCount === 0) { + return false; + } + const boundary = Math.min(this.snapshotQueuedSessionEventCount, queue.length); + let matchIndex = -1; + for (let index = 0; index < boundary; index += 1) { + const event = queue[index]; + if (event !== undefined && autonomousTriggerEventKey(event) === key) { + matchIndex = index; + break; + } + } + if (matchIndex === -1) return false; + + const [event] = queue.splice(matchIndex, 1); + this.snapshotQueuedSessionEventCount -= 1; + if (event === undefined) return false; + await this.enqueueSessionEvent(event); + return true; } /** @@ -849,10 +1034,25 @@ export class AcpSession { beginAssistantTurn: () => void; recordToolCall: (toolCallId: string) => void; lookupToolCallTurnId: (toolCallId: string) => number | undefined; + backgroundTasks: ReadonlyMap; }, - ): Promise { + ): Promise { switch (message.role) { - case 'user': + case 'user': { + const autonomousTrigger = persistedAutonomousTrigger( + message, + ctx.backgroundTasks, + ); + if (autonomousTrigger !== undefined) { + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: autonomousTrigger.text }, + }, + }); + return autonomousTrigger.key; + } for (const part of message.content) { if (part.type === 'text' && part.text) { await conn.sessionUpdate({ @@ -864,7 +1064,8 @@ export class AcpSession { }); } } - return; + return undefined; + } case 'assistant': { ctx.beginAssistantTurn(); const turnId = ctx.getTurnId(); @@ -875,7 +1076,7 @@ export class AcpSession { ctx.recordToolCall(toolCall.id); await this.replaySyntheticToolCall(toolCall, sessionId, conn, turnId); } - return; + return undefined; } case 'tool': { const rawToolCallId = message.toolCallId; @@ -884,7 +1085,7 @@ export class AcpSession { // than crash. The on-disk session is the source of truth; // we cannot synthesize a missing id. log.warn('acp: replayHistory skipped tool message with no toolCallId', { sessionId }); - return; + return undefined; } const turnId = ctx.lookupToolCallTurnId(rawToolCallId); if (turnId === undefined) { @@ -892,7 +1093,7 @@ export class AcpSession { sessionId, toolCallId: rawToolCallId, }); - return; + return undefined; } const isError = message.isError === true; await conn.sessionUpdate({ @@ -904,11 +1105,11 @@ export class AcpSession { content: toolMessageContentToAcpToolCallContent(message.content), }, }); - return; + return undefined; } default: // system / unknown roles — ACP has no analogue; skip. - return; + return undefined; } } @@ -976,19 +1177,19 @@ export class AcpSession { * responsibilities separate prevents prompt-driven turns from being emitted * twice while still making runtime-initiated turns visible. */ - private handleSessionEvent(event: Event): void { + private async handleSessionEvent(event: Event): Promise { if (this.disposed) return; if (!this.isFromMainAgent(event)) return; if (event.type === 'background.task.terminated' || event.type === 'task.terminated') { const text = taskCompletionDisplayText(event.info); if (text !== undefined) { - this.emitAgentInitiatedUserMessage(text, 'background task'); + await this.emitAgentInitiatedUserMessage(text, 'background task'); } return; } if (event.type === 'cron.fired') { - this.emitAgentInitiatedUserMessage(event.prompt, 'cron'); + await this.emitAgentInitiatedUserMessage(event.prompt, 'cron'); return; } if (event.type === 'turn.started') { @@ -1004,7 +1205,7 @@ export class AcpSession { } if (event.type === 'assistant.delta') { - this.conn + await this.conn .sessionUpdate(assistantDeltaToSessionUpdate(this.id, event)) .catch((error) => { log.warn('acp: failed to push agent_message_chunk', { @@ -1015,7 +1216,7 @@ export class AcpSession { return; } if (event.type === 'thinking.delta') { - this.conn + await this.conn .sessionUpdate(thinkingDeltaToSessionUpdate(this.id, event)) .catch((error) => { log.warn('acp: failed to push agent_thought_chunk', { @@ -1029,7 +1230,7 @@ export class AcpSession { this.argsByToolCall.set(event.toolCallId, { args: stringifyArgs(event.args) }); const startedWireId = acpToolCallId(event.turnId, event.toolCallId); if (this.startedToolCalls.has(startedWireId)) { - this.conn + await this.conn .sessionUpdate(toolCallStartedUpgradeToSessionUpdate(this.id, event)) .catch((error) => { log.warn('acp: failed to push tool_call_update (start upgrade)', { @@ -1040,7 +1241,7 @@ export class AcpSession { }); } else { this.startedToolCalls.add(startedWireId); - this.conn + await this.conn .sessionUpdate(toolCallStartToSessionUpdate(this.id, event)) .catch((error) => { log.warn('acp: failed to push tool_call', { @@ -1053,7 +1254,7 @@ export class AcpSession { if (event.display) { const planNote = planFromDisplayBlock(this.id, event.turnId, event.display); if (planNote !== null) { - this.conn.sessionUpdate(planNote).catch((error) => { + await this.conn.sessionUpdate(planNote).catch((error) => { log.warn('acp: failed to push plan', { sessionId: this.id, error: error instanceof Error ? error.message : String(error), @@ -1069,7 +1270,7 @@ export class AcpSession { const initial = event.argumentsPart ?? ''; this.argsByToolCall.set(event.toolCallId, { args: initial }); this.startedToolCalls.add(deltaWireId); - this.conn + await this.conn .sessionUpdate(toolCallLazyCreateToSessionUpdate(this.id, event)) .catch((error) => { log.warn('acp: failed to push tool_call (lazy create from delta)', { @@ -1085,7 +1286,7 @@ export class AcpSession { accumulator = { args: '' }; this.argsByToolCall.set(event.toolCallId, accumulator); } - this.conn + await this.conn .sessionUpdate(toolCallDeltaToSessionUpdate(this.id, event, accumulator)) .catch((error) => { log.warn('acp: failed to push tool_call_update (delta)', { @@ -1099,7 +1300,7 @@ export class AcpSession { if (event.type === 'tool.progress') { const notification = toolProgressToSessionUpdate(this.id, event); if (notification === null) return; - this.conn.sessionUpdate(notification).catch((error) => { + await this.conn.sessionUpdate(notification).catch((error) => { log.warn('acp: failed to push tool_call_update (progress)', { sessionId: this.id, toolCallId: event.toolCallId, @@ -1109,7 +1310,7 @@ export class AcpSession { return; } if (event.type === 'tool.result') { - this.conn + await this.conn .sessionUpdate(toolResultToSessionUpdate(this.id, event)) .catch((error) => { log.warn('acp: failed to push tool_call_update (result)', { @@ -1135,9 +1336,12 @@ export class AcpSession { * public fields needed for ACP instead, so this projection never reads the * context message or serializes its internal XML. */ - private emitAgentInitiatedUserMessage(text: string, source: string): void { + private async emitAgentInitiatedUserMessage( + text: string, + source: string, + ): Promise { if (text.length === 0) return; - this.conn + await this.conn .sessionUpdate({ sessionId: this.id, update: { @@ -1926,6 +2130,171 @@ function matchesPromptCorrelation( ); } +/** + * Autonomous task/cron turns persist their model-facing control envelope as a + * user-role context message. A live ACP bridge projects the corresponding + * lifecycle event as display-safe text, so replaying the envelope would both + * leak internal XML and represent the same trigger twice. + * + * Require both the autonomous origin and a known envelope tag. This preserves + * literal XML entered by a user and remains compatible with v2's runtime + * `task` origin, whose wire shape is intentionally cast to the v1 SDK type. + */ +function modelInternalAutonomousOrigin( + message: ContextMessage, +): + | { + readonly kind: 'background_task' | 'task'; + readonly taskId?: string; + readonly status?: string; + } + | { + readonly kind: 'cron_job'; + readonly jobId?: unknown; + readonly cron?: unknown; + readonly recurring?: unknown; + readonly coalescedCount?: unknown; + readonly stale?: unknown; + } + | undefined { + const originKind = (message.origin as { readonly kind?: string } | undefined)?.kind; + if ( + originKind !== 'background_task' && + originKind !== 'task' && + originKind !== 'cron_job' + ) { + return undefined; + } + const hasKnownEnvelope = message.content.some( + (part) => + part.type === 'text' && + /^\s*<(?:notification|cron-fire)\b/u.test(part.text), + ); + if (!hasKnownEnvelope) return undefined; + if (originKind === 'cron_job') { + const origin = message.origin as unknown as Record; + return { + kind: originKind, + jobId: origin['jobId'], + cron: origin['cron'], + recurring: origin['recurring'], + coalescedCount: origin['coalescedCount'], + stale: origin['stale'], + }; + } + const origin = message.origin as { + readonly taskId?: unknown; + readonly status?: unknown; + }; + return { + kind: originKind, + taskId: typeof origin.taskId === 'string' ? origin.taskId : undefined, + status: typeof origin.status === 'string' ? origin.status : undefined, + }; +} + +/** + * Reconstruct the display-safe user half of a persisted autonomous turn. + * + * Live projection uses the task lifecycle metadata or the cron event's prompt, + * never the model-facing control envelope. Cold replay must make the same + * distinction: replacing the user message keeps the following assistant/tool + * messages attached to a visible trigger without exposing internal XML. + */ +function persistedAutonomousTrigger( + message: ContextMessage, + backgroundTasks: ReadonlyMap, +): { readonly key?: string; readonly text: string } | undefined { + const origin = modelInternalAutonomousOrigin(message); + if (origin === undefined) return undefined; + if (origin.kind === 'cron_job') { + const prompt = cronPromptFromInternalMessage(message); + return { + key: cronAutonomousTriggerKey(origin, prompt), + text: prompt ?? 'Scheduled task fired.', + }; + } + + const task = + origin.taskId === undefined + ? undefined + : backgroundTasks.get(origin.taskId); + const description = task?.description.trim(); + const subject = + description === undefined || description.length === 0 + ? task === undefined + ? 'Background task' + : `Background ${task.kind} task` + : description; + return { + key: + origin.taskId === undefined + ? undefined + : taskAutonomousTriggerKey(origin.taskId, origin.status), + text: + taskStatusDisplayText(subject, origin.status ?? task?.status) ?? + 'Background task finished.', + }; +} + +function autonomousTriggerEventKey(event: Event): string | undefined { + if (event.type === 'background.task.terminated' || event.type === 'task.terminated') { + return taskAutonomousTriggerKey(event.info.taskId, event.info.status); + } + if (event.type !== 'cron.fired') return undefined; + return cronAutonomousTriggerKey(event.origin, event.prompt); +} + +function taskAutonomousTriggerKey( + taskId: string, + status: string | undefined, +): string { + return JSON.stringify(['task', taskId, status ?? 'unknown']); +} + +function cronAutonomousTriggerKey( + origin: { + readonly jobId?: unknown; + readonly cron?: unknown; + readonly recurring?: unknown; + readonly coalescedCount?: unknown; + readonly stale?: unknown; + }, + prompt: string | undefined, +): string | undefined { + if ( + typeof origin.jobId !== 'string' || + typeof origin.cron !== 'string' || + typeof origin.recurring !== 'boolean' || + typeof origin.coalescedCount !== 'number' || + typeof origin.stale !== 'boolean' + ) { + return undefined; + } + return JSON.stringify([ + 'cron', + origin.jobId, + origin.cron, + origin.recurring, + origin.coalescedCount, + origin.stale, + prompt ?? '', + ]); +} + +function cronPromptFromInternalMessage(message: ContextMessage): string | undefined { + const prefixPattern = /^\s*]*>\n\n/u; + const suffix = '\n\n'; + for (const part of message.content) { + if (part.type !== 'text') continue; + const prefix = prefixPattern.exec(part.text)?.[0]; + if (prefix === undefined || !part.text.endsWith(suffix)) continue; + const prompt = part.text.slice(prefix.length, -suffix.length); + if (prompt.length > 0) return prompt; + } + return undefined; +} + /** * Build the user-visible portion of a background-task completion without * exposing the model-facing `` XML or its output-control @@ -1947,7 +2316,14 @@ function taskCompletionDisplayText( description.length > 0 ? description : `Background ${info.kind} task`; - switch (info.status) { + return taskStatusDisplayText(subject, info.status); +} + +function taskStatusDisplayText( + subject: string, + status: string | undefined, +): string | undefined { + switch (status) { case 'completed': return `${subject} completed.`; case 'failed': @@ -1958,6 +2334,8 @@ function taskCompletionDisplayText( return `${subject} was stopped.`; case 'lost': return `${subject} was lost.`; + case undefined: + return undefined; } } diff --git a/packages/acp-adapter/test/_helpers/real-engine-rig.ts b/packages/acp-adapter/test/_helpers/real-engine-rig.ts index b5417f2a8f..5d1cf3fcb0 100644 --- a/packages/acp-adapter/test/_helpers/real-engine-rig.ts +++ b/packages/acp-adapter/test/_helpers/real-engine-rig.ts @@ -26,8 +26,9 @@ import { import { runAcpServerWithStream } from '../../src/server'; const TEST_IDENTITY = { - userAgentProduct: 'kimi-code-cli', + productName: 'kimi-code-cli', version: '0.0.0-test', + platform: 'kimi_code_cli', } as const; const API_KEY = 'YOUR_API_KEY'; const MODEL = 'stub-model'; @@ -56,12 +57,18 @@ class LoopbackModelServer { private constructor( private readonly server: Server, private readonly replies: readonly ModelReply[], + private readonly beforeReply: + | ((request: ModelRequest, index: number) => Promise | void) + | undefined, port: number, ) { this.baseUrl = `http://127.0.0.1:${String(port)}/v1`; } - static async start(replies: readonly ModelReply[]): Promise { + static async start( + replies: readonly ModelReply[], + beforeReply?: (request: ModelRequest, index: number) => Promise | void, + ): Promise { let fixture: LoopbackModelServer | undefined; const server = createServer((request, response) => { void fixture?.handle(request, response); @@ -69,7 +76,7 @@ class LoopbackModelServer { server.listen(0, '127.0.0.1'); await once(server, 'listening'); const address = server.address() as AddressInfo; - fixture = new LoopbackModelServer(server, replies, address.port); + fixture = new LoopbackModelServer(server, replies, beforeReply, address.port); return fixture; } @@ -103,13 +110,16 @@ class LoopbackModelServer { try { const body = await readJsonBody(request); - const reply = this.replies[this.requests.length]; - this.requests.push({ authorization, body }); + const index = this.requests.length; + const reply = this.replies[index]; + const modelRequest = { authorization, body }; + this.requests.push(modelRequest); if (reply === undefined) { respondJson(response, 500, { error: { message: 'unexpected model request' } }); return; } - respondSse(response, reply, this.requests.length); + await this.beforeReply?.(modelRequest, index); + respondSse(response, reply, index + 1); } catch (error) { respondJson(response, 400, { error: { message: error instanceof Error ? error.message : String(error) }, @@ -128,6 +138,12 @@ export class CollectingClient implements Client { readonly onAbort: () => void; }>(); + constructor( + private readonly onSessionUpdate: + | ((notification: SessionNotification) => Promise | void) + | undefined = undefined, + ) {} + async requestPermission(_request: RequestPermissionRequest): Promise { throw new Error('requestPermission should not be called in the real-engine ACP rig'); } @@ -140,6 +156,7 @@ export class CollectingClient implements Client { waiter.signal.removeEventListener('abort', waiter.onAbort); waiter.resolve(notification); } + await this.onSessionUpdate?.(notification); } async writeTextFile(request: WriteTextFileRequest): Promise { @@ -183,9 +200,11 @@ export interface RealEngineRig { readonly client: ClientSideConnection; readonly collecting: CollectingClient; readonly harness: KimiHarness; + readonly homeDir: string; readonly modelRequests: readonly ModelRequest[]; readonly session: Session; readonly workDir: string; + closeRuntime(): Promise; close(): Promise; } @@ -195,24 +214,45 @@ export async function createRealEngineRig(options: { readonly workDir: string; readonly replies: readonly ModelReply[]; readonly additionalConfig?: string; + readonly session?: { readonly kind: 'load'; readonly id: string }; + readonly onSessionUpdate?: ( + notification: SessionNotification, + ) => Promise | void; + readonly beforeModelReply?: ( + request: ModelRequest, + index: number, + ) => Promise | void; }): Promise { - const modelServer = await LoopbackModelServer.start(options.replies); + const modelServer = await LoopbackModelServer.start( + options.replies, + options.beforeModelReply, + ); let harness: KimiHarness | undefined; let clientToAgent: TransformStream | undefined; let agentToClient: TransformStream | undefined; let client: ClientSideConnection | undefined; let serverRun: Promise | undefined; - const cleanup = () => - runCleanupSteps([ + let runtimeClosePromise: Promise | undefined; + const closeRuntime = (): Promise => { + runtimeClosePromise ??= runCleanupSteps([ () => clientToAgent?.writable.close(), () => serverRun, () => agentToClient?.writable.close(), () => client?.closed, () => harness?.close(), () => modelServer.close(), + ]); + return runtimeClosePromise; + }; + let closePromise: Promise | undefined; + const cleanup = (): Promise => { + closePromise ??= runCleanupSteps([ + closeRuntime, () => rm(options.homeDir, { recursive: true, force: true }), () => rm(options.workDir, { recursive: true, force: true }), ]); + return closePromise; + }; try { await writeFile( `${options.homeDir}/config.toml`, @@ -227,25 +267,35 @@ export async function createRealEngineRig(options: { const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); serverRun = runAcpServerWithStream(harness, agentStream); - const collecting = new CollectingClient(); + const collecting = new CollectingClient(options.onSessionUpdate); client = new ClientSideConnection(() => collecting, clientStream); - const response = await client.newSession({ cwd: options.workDir, mcpServers: [] }); - const session = harness.getSession(response.sessionId); + const sessionId = + options.session?.kind === 'load' + ? options.session.id + : (await client.newSession({ cwd: options.workDir, mcpServers: [] })).sessionId; + if (options.session?.kind === 'load') { + await client.loadSession({ + sessionId, + cwd: options.workDir, + mcpServers: [], + }); + } + const session = harness.getSession(sessionId); if (session === undefined) { - throw new Error(`Harness did not retain ACP session ${response.sessionId}`); + throw new Error(`Harness did not retain ACP session ${sessionId}`); } - let closePromise: Promise | undefined; return { client, collecting, harness, + homeDir: options.homeDir, modelRequests: modelServer.requests, session, workDir: options.workDir, + closeRuntime, close() { - closePromise ??= cleanup(); - return closePromise; + return cleanup(); }, }; } catch (error) { diff --git a/packages/acp-adapter/test/agent-initiated-engine.e2e.test.ts b/packages/acp-adapter/test/agent-initiated-engine.e2e.test.ts index 32e7d34e37..f076691b4d 100644 --- a/packages/acp-adapter/test/agent-initiated-engine.e2e.test.ts +++ b/packages/acp-adapter/test/agent-initiated-engine.e2e.test.ts @@ -1,11 +1,12 @@ /** - * Scenario: an idle engine launches work independently of any ACP prompt request. - * Responsibilities: project the safe trigger, tool lifecycle, and final reply over ACP NDJSON. + * Scenario: an idle or cold-loaded engine launches work independently of any ACP prompt request. + * Responsibilities: preserve history/live ordering and project each safe trigger, tool lifecycle, + * and final reply exactly once over ACP NDJSON. * Wiring: real v1/v2 harnesses, engines, node SDK, ACP connections, filesystem, and shell task; * only the remote Chat Completions endpoint is stubbed on loopback. * Run: pnpm --filter @moonshot-ai/acp-adapter exec vitest run test/agent-initiated-engine.e2e.test.ts */ -import { mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -20,16 +21,78 @@ import { const LONG_RUNNING_COMMAND = "node -e 'setInterval(()=>{},1e3)'"; const SAFE_TERMINATION_TEXT = 'Test background task was stopped.'; +const PERSISTED_USER_TEXT = 'Persist this cron reminder before restart.'; +const PERSISTED_ASSISTANT_TEXT = 'Cron reminder persisted.'; +const RESTORE_BOUNDARY_TASK_DESCRIPTION = 'Restore boundary task'; +const RESTORED_TASK_TRIGGER = 'Restore boundary task was lost.'; +const RESTORED_CRON_PROMPT = 'Review the restored fixture now.'; +const RESTORED_ASSISTANT_TEXT = 'Restored cron review finished.'; +const CLOCK_START_MS = 1_735_689_600_000; -const rigs: RealEngineRig[] = []; +const rigs = new Set(); +const rigCreations: Array> = []; +const environmentRestorers: Array<() => void> = []; +const testProcesses = new Map(); +const testGateReleasers: Array<() => void> = []; afterEach(async () => { - for (const rig of rigs.splice(0).toReversed()) { - await rig.close(); + const cleanupErrors: unknown[] = []; + for (const release of testGateReleasers.splice(0).toReversed()) { + try { + release(); + } catch (error) { + cleanupErrors.push(error); + } + } + + const creationResults = await Promise.allSettled(rigCreations.splice(0)); + for (const result of creationResults) { + if (result.status === 'rejected') cleanupErrors.push(result.reason); + } + const closingRigs = [...rigs].toReversed(); + rigs.clear(); + + for (const rig of closingRigs) { + await registerActiveTestProcesses(rig); + } + for (const rig of closingRigs) { + try { + await rig.closeRuntime(); + } catch (error) { + cleanupErrors.push(error); + } + } + const closingProcesses = [...testProcesses]; + testProcesses.clear(); + for (const [pid, statePath] of closingProcesses) { + try { + await terminateTestProcess(pid, statePath); + } catch (error) { + cleanupErrors.push(error); + } + } + for (const rig of closingRigs) { + try { + await rig.close(); + } catch (error) { + cleanupErrors.push(error); + } + } + for (const restore of environmentRestorers.splice(0).toReversed()) { + try { + restore(); + } catch (error) { + cleanupErrors.push(error); + } + } + + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + if (cleanupErrors.length > 1) { + throw new AggregateError(cleanupErrors, 'Failed to clean up real-engine ACP test resources'); } }); -describe('ACP idle engine turn projection', () => { +describe.sequential('ACP idle engine turn projection', () => { it.each(['v1', 'v2'] as const)( 'projects a complete idle task-notification turn through the %s engine', async (engine) => { @@ -164,6 +227,397 @@ describe('ACP idle engine turn projection', () => { }, 30_000, ); + + it.each(['v1', 'v2'] as const)( + 'replays a completed autonomous task as one safe turn after a second cold load through the %s engine', + async (engine) => { + const original = await createAutonomousRig(engine); + await expect( + original.client.prompt({ + sessionId: original.session.id, + prompt: [{ type: 'text', text: 'Start the test background task.' }], + }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + const task = (await original.session.listBackgroundTasks({ activeOnly: true }))[0]; + if (task === undefined) { + throw new Error(`${engine} did not retain the background task`); + } + const autonomousTurnEnded = waitForSessionEvent( + original.session, + (event) => + event.type === 'turn.ended' && + event.reason === 'completed', + `${engine} completed autonomous turn`, + ); + const autonomousReply = original.collecting.waitForUpdate( + (notification) => + notification.update.sessionUpdate === 'agent_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === 'Autonomous review finished.', + `${engine} autonomous reply before restart`, + ); + + await original.session.stopBackgroundTask(task.taskId); + await Promise.all([autonomousTurnEnded, autonomousReply]); + const sessionId = original.session.id; + await original.closeRuntime(); + + const reloaded = await trackRig(createRealEngineRig({ + engine, + homeDir: original.homeDir, + workDir: original.workDir, + replies: [], + session: { kind: 'load', id: sessionId }, + })); + + const taskTriggers = reloaded.collecting.updates.filter( + (notification) => + notification.update.sessionUpdate === 'user_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === SAFE_TERMINATION_TEXT, + ); + const toolCalls = reloaded.collecting.updates.filter( + (notification) => + notification.update.sessionUpdate === 'tool_call' && + notification.update.toolCallId.includes('call_read_fixture'), + ); + const replies = reloaded.collecting.updates.filter( + (notification) => + notification.update.sessionUpdate === 'agent_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === 'Autonomous review finished.', + ); + expect(taskTriggers).toHaveLength(1); + expect(toolCalls).toHaveLength(1); + expect(replies).toHaveLength(1); + const toolCall = toolCalls[0]; + if (toolCall?.update.sessionUpdate !== 'tool_call') { + throw new Error(`${engine} did not replay the autonomous Read tool`); + } + const toolCallId = toolCall.update.toolCallId; + const completedToolCalls = reloaded.collecting.updates.filter( + (notification) => + notification.update.sessionUpdate === 'tool_call_update' && + notification.update.toolCallId === toolCallId && + notification.update.status === 'completed', + ); + expect(completedToolCalls).toHaveLength(1); + + const orderedUpdates = [ + taskTriggers[0], + toolCall, + completedToolCalls[0], + replies[0], + ].map((notification) => + notification === undefined + ? -1 + : reloaded.collecting.updates.indexOf(notification), + ); + expect(orderedUpdates.every((index) => index >= 0)).toBe(true); + expect(orderedUpdates).toEqual( + orderedUpdates.toSorted((left, right) => left - right), + ); + expect(new Set(orderedUpdates).size).toBe(orderedUpdates.length); + expect(reloaded.modelRequests).toHaveLength(0); + + const wire = JSON.stringify(reloaded.collecting.updates); + expect(wire).not.toContain(' { + const signalListenersBefore = process.listenerCount('SIGUSR1'); + const homeDir = await mkdtemp(join(tmpdir(), `kimi-acp-${engine}-resume-home-`)); + const workDir = await mkdtemp(join(tmpdir(), `kimi-acp-${engine}-resume-work-`)); + const clockPath = join(homeDir, 'cron-clock.txt'); + const readPath = join(workDir, 'restored-fixture.txt'); + await writeFile(clockPath, String(CLOCK_START_MS), 'utf-8'); + await writeFile(readPath, 'restored fixture contents', 'utf-8'); + setTestEnvironment('KIMI_CRON_CLOCK', `file:${clockPath}`); + setTestEnvironment('KIMI_CRON_MANUAL_TICK', '1'); + setTestEnvironment('KIMI_CRON_NO_JITTER', '1'); + setTestEnvironment('KIMI_CRON_NO_STALE', '1'); + setTestEnvironment('KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT', '1'); + + const original = await trackRig(createRealEngineRig({ + engine, + homeDir, + workDir, + replies: [ + { + kind: 'tool', + id: 'call_create_restore_boundary_task', + name: 'Bash', + arguments: { + command: LONG_RUNNING_COMMAND, + description: RESTORE_BOUNDARY_TASK_DESCRIPTION, + run_in_background: true, + }, + }, + { + kind: 'tool', + id: 'call_create_cron', + name: 'CronCreate', + arguments: { + cron: '* * * * *', + prompt: RESTORED_CRON_PROMPT, + recurring: false, + }, + }, + { kind: 'text', text: PERSISTED_ASSISTANT_TEXT }, + ], + })); + await expect( + original.client.prompt({ + sessionId: original.session.id, + prompt: [{ type: 'text', text: PERSISTED_USER_TEXT }], + }), + ).resolves.toEqual({ stopReason: 'end_turn' }); + const persistedTasks = await original.session.listBackgroundTasks({ + activeOnly: true, + }); + expect(persistedTasks).toEqual([ + expect.objectContaining({ + kind: 'process', + command: LONG_RUNNING_COMMAND, + description: RESTORE_BOUNDARY_TASK_DESCRIPTION, + detached: true, + status: 'running', + }), + ]); + const persistedTask = persistedTasks[0]; + if (persistedTask?.kind !== 'process') { + throw new Error(`${engine} did not retain the restore-boundary process`); + } + const sessionDir = original.session.summary?.sessionDir; + if (sessionDir === undefined) { + throw new Error(`${engine} session summary did not expose its persistence directory`); + } + const persistedTaskStatePath = join( + sessionDir, + 'agents', + 'main', + 'tasks', + `${persistedTask.taskId}.json`, + ); + testProcesses.set(persistedTask.pid, persistedTaskStatePath); + expect(original.modelRequests).toHaveLength(3); + await expect(original.session.getCronTasks()).resolves.toEqual({ + tasks: [ + expect.objectContaining({ + cron: '* * * * *', + recurring: false, + nextFireAt: 1_735_689_660_000, + }), + ], + }); + + const sessionId = original.session.id; + await original.closeRuntime(); + expect(process.listenerCount('SIGUSR1')).toBe(signalListenersBefore); + await writeFile(clockPath, String(CLOCK_START_MS + 60_000), 'utf-8'); + + const historyReached = controlledPromise(); + const releaseHistory = controlledPromise(); + const modelRequestReached = controlledPromise(); + const releaseModelReply = controlledPromise(); + testGateReleasers.push(releaseHistory.resolve, releaseModelReply.resolve); + let heldHistory = false; + let loadSettled = false; + const loadingRig = trackRig(createRealEngineRig({ + engine, + homeDir, + workDir, + session: { kind: 'load', id: sessionId }, + replies: [ + { + kind: 'tool', + id: 'call_restore_read', + name: 'Read', + arguments: { path: readPath }, + }, + { kind: 'text', text: RESTORED_ASSISTANT_TEXT }, + ], + onSessionUpdate: async (notification) => { + if ( + heldHistory || + notification.update.sessionUpdate !== 'agent_message_chunk' || + notification.update.content.type !== 'text' || + notification.update.content.text !== PERSISTED_ASSISTANT_TEXT + ) { + return; + } + heldHistory = true; + historyReached.resolve(); + await releaseHistory.promise; + }, + beforeModelReply: async (_request, index) => { + if (index !== 0) return; + modelRequestReached.resolve(); + await releaseModelReply.promise; + }, + })).finally(() => { + loadSettled = true; + }); + + const firstBoundary = await Promise.race([ + historyReached.promise.then(() => 'history' as const), + loadingRig.then(() => 'load' as const), + ]); + expect(firstBoundary).toBe('history'); + try { + expect(loadSettled).toBe(false); + expect(process.emit('SIGUSR1')).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + expect(loadSettled).toBe(false); + } finally { + releaseHistory.resolve(); + } + + const restored = await loadingRig; + const restoredTaskTrigger = restored.collecting.waitForUpdate( + (notification) => + notification.update.sessionUpdate === 'user_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === RESTORED_TASK_TRIGGER, + `${engine} restored task safe trigger`, + ); + const restoredCronTrigger = restored.collecting.waitForUpdate( + (notification) => + notification.update.sessionUpdate === 'user_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === RESTORED_CRON_PROMPT, + `${engine} restored cron safe trigger`, + ); + try { + await Promise.all([ + restoredTaskTrigger, + restoredCronTrigger, + modelRequestReached.promise, + ]); + expect( + restored.collecting.updates.filter( + (notification) => + notification.update.sessionUpdate === 'tool_call' && + notification.update.toolCallId.includes('call_restore_read'), + ), + ).toHaveLength(0); + expect( + restored.collecting.updates.filter( + (notification) => + notification.update.sessionUpdate === 'agent_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === RESTORED_ASSISTANT_TEXT, + ), + ).toHaveLength(0); + } finally { + releaseModelReply.resolve(); + } + + await restored.collecting.waitForUpdate( + (notification) => + notification.update.sessionUpdate === 'agent_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === RESTORED_ASSISTANT_TEXT, + `${engine} restored cron assistant reply`, + ); + expect(restored.modelRequests).toHaveLength(2); + expect(restored.modelRequests[0]?.body).toMatchObject({ + model: 'stub-model', + stream: true, + tools: expect.arrayContaining([ + expect.objectContaining({ + function: expect.objectContaining({ name: 'Read' }), + }), + ]), + }); + expect(restored.modelRequests[1]?.body).toMatchObject({ + messages: expect.arrayContaining([ + expect.objectContaining({ + role: 'tool', + content: expect.stringContaining('restored fixture contents'), + }), + ]), + }); + + const restoredTaskTriggers = restored.collecting.updates.filter( + (notification) => + notification.update.sessionUpdate === 'user_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === RESTORED_TASK_TRIGGER, + ); + const restoredCronTriggers = restored.collecting.updates.filter( + (notification) => + notification.update.sessionUpdate === 'user_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === RESTORED_CRON_PROMPT, + ); + const liveToolCalls = restored.collecting.updates.filter( + (notification) => + notification.update.sessionUpdate === 'tool_call' && + notification.update.toolCallId.includes('call_restore_read'), + ); + expect(restoredTaskTriggers).toHaveLength(1); + expect(restoredCronTriggers).toHaveLength(1); + expect(liveToolCalls).toHaveLength(1); + const liveToolCall = liveToolCalls[0]; + if (liveToolCall?.update.sessionUpdate !== 'tool_call') { + throw new Error(`${engine} did not project the restored Read tool`); + } + const liveToolCallId = liveToolCall.update.toolCallId; + const completedLiveToolCalls = restored.collecting.updates.filter( + (notification) => + notification.update.sessionUpdate === 'tool_call_update' && + notification.update.toolCallId === liveToolCallId && + notification.update.status === 'completed', + ); + const liveAssistantReplies = restored.collecting.updates.filter( + (notification) => + notification.update.sessionUpdate === 'agent_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === RESTORED_ASSISTANT_TEXT, + ); + expect(completedLiveToolCalls).toHaveLength(1); + expect(liveAssistantReplies).toHaveLength(1); + + const orderedUpdates = [ + restored.collecting.updates.find( + (notification) => + notification.update.sessionUpdate === 'agent_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === PERSISTED_ASSISTANT_TEXT, + ), + restoredTaskTriggers[0], + restoredCronTriggers[0], + liveToolCall, + completedLiveToolCalls[0], + liveAssistantReplies[0], + ].map((notification) => + notification === undefined + ? -1 + : restored.collecting.updates.indexOf(notification), + ); + expect(orderedUpdates.every((index) => index >= 0)).toBe(true); + expect(orderedUpdates).toEqual( + orderedUpdates.toSorted((left, right) => left - right), + ); + expect(new Set(orderedUpdates).size).toBe(orderedUpdates.length); + + const wire = JSON.stringify(restored.collecting.updates); + expect(wire).not.toContain(' { @@ -171,7 +625,7 @@ async function createAutonomousRig(engine: Engine): Promise { const workDir = await mkdtemp(join(tmpdir(), `kimi-acp-${engine}-work-`)); const readPath = join(workDir, 'fixture.txt'); await writeFile(readPath, 'fixture contents', 'utf-8'); - const rig = await createRealEngineRig({ + const rig = await trackRig(createRealEngineRig({ engine, homeDir, workDir, @@ -195,7 +649,125 @@ async function createAutonomousRig(engine: Engine): Promise { }, { kind: 'text', text: 'Autonomous review finished.' }, ], - }); - rigs.push(rig); + })); return rig; } + +function trackRig(creation: Promise): Promise { + const tracked = creation.then((rig) => { + rigs.add(rig); + return rig; + }); + rigCreations.push(tracked); + void tracked.catch(() => undefined); + return tracked; +} + +async function registerActiveTestProcesses(rig: RealEngineRig): Promise { + const sessionDir = rig.session.summary?.sessionDir; + if (sessionDir === undefined) return; + try { + const tasks = await rig.session.listBackgroundTasks({ activeOnly: true }); + for (const task of tasks) { + if (task.kind !== 'process') continue; + testProcesses.set( + task.pid, + join( + sessionDir, + 'agents', + 'main', + 'tasks', + `${task.taskId}.json`, + ), + ); + } + } catch { + // A test may have deliberately closed this runtime before afterEach. Any + // process discovered while it was live was already registered explicitly. + } +} + +function controlledPromise(): { + readonly promise: Promise; + readonly resolve: () => void; +} { + let resolve!: () => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +function setTestEnvironment(name: string, value: string): void { + const hadValue = Object.prototype.hasOwnProperty.call(process.env, name); + const previous = process.env[name]; + process.env[name] = value; + environmentRestorers.push(() => { + if (hadValue) { + process.env[name] = previous; + } else { + delete process.env[name]; + } + }); +} + +async function terminateTestProcess( + pid: number, + statePath: string, +): Promise { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (isMissingProcess(error)) { + return; + } + throw error; + } + + const signal = AbortSignal.timeout(5_000); + while (!signal.aborted) { + try { + const persisted: unknown = JSON.parse(await readFile(statePath, 'utf-8')); + const status = + typeof persisted === 'object' && + persisted !== null && + 'status' in persisted + ? persisted.status + : undefined; + if ( + status === 'completed' || + status === 'failed' || + status === 'killed' || + status === 'timed_out' + ) { + return; + } + } catch (error) { + if (!isMissingFile(error)) throw error; + } + await new Promise((resolve) => { + setImmediate(resolve); + }); + } + throw new Error( + `Timed out waiting for test process ${String(pid)} to persist its terminal state`, + ); +} + +function isMissingProcess(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ESRCH' + ); +} + +function isMissingFile(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ); +} diff --git a/packages/acp-adapter/test/e2e-happy-path.test.ts b/packages/acp-adapter/test/e2e-happy-path.test.ts index 2f4e3fa301..4fe9ab6b46 100644 --- a/packages/acp-adapter/test/e2e-happy-path.test.ts +++ b/packages/acp-adapter/test/e2e-happy-path.test.ts @@ -377,7 +377,7 @@ describe('AcpServer end-to-end happy path', () => { const harness = makeHarness(session); const { agentStream, clientStream } = makeInMemoryStreamPair(); - const agentConnection = new AgentSideConnection( + void new AgentSideConnection( (connection) => new AcpServer(harness, connection), agentStream, ); @@ -476,20 +476,12 @@ describe('AcpServer end-to-end happy path', () => { reason: 'completed', } as Event); - const barrier = collecting.waitForUpdate( + await collecting.waitForUpdate( (notification) => - (notification.update._meta as { barrier?: string } | null | undefined)?.barrier === - 'after-autonomous-turn', + notification.update.sessionUpdate === 'agent_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === 'Scheduled review finished.', ); - await agentConnection.sessionUpdate({ - update: { - sessionUpdate: 'available_commands_update', - availableCommands: [], - _meta: { barrier: 'after-autonomous-turn' }, - }, - sessionId, - }); - await barrier; // ACP projects only the display-safe task lifecycle summary. Internal // task identifiers and stop details must not cross the wire. @@ -1022,6 +1014,577 @@ describe('AcpServer end-to-end happy path', () => { expect(listenerCount()).toBe(1); }); + it('replays a frozen history once before post-snapshot events during cold load', async () => { + const sessionId = 'sess-e2e-cold-load-events'; + const { session, emit, listenerCount } = makeScriptedSession(sessionId, []); + Object.assign(session, { + getResumeState: () => ({ + agents: { + main: { + context: { + history: [ + { + role: 'user', + content: [{ type: 'text', text: 'Earlier question.' }], + toolCalls: [], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Earlier answer.' }], + toolCalls: [], + }, + { + role: 'user', + content: [ + { + type: 'text', + text: + '\n' + + '\nReview the newly scheduled report.\n\n', + }, + ], + toolCalls: [], + origin: { + kind: 'cron_job', + jobId: 'cron-load-example', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + }, + ], + tokenCount: 0, + }, + }, + }, + }), + }); + const rawListeners = new Set<(event: Event) => void>(); + let rawUnsubscribeCount = 0; + const preSnapshotEvents = [ + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 1, + origin: { kind: 'user', promptId: 'prompt-earlier' }, + }, + { + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 1, + delta: 'Earlier answer.', + }, + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 1, + reason: 'completed', + }, + ] as const satisfies readonly Event[]; + const duringSnapshotEvents = [ + { + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 1, + delta: 'Earlier answer.', + }, + { + type: 'cron.fired', + sessionId, + agentId: 'main', + origin: { + kind: 'cron_job', + jobId: 'cron-load-example', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + prompt: 'Review the newly scheduled report.', + }, + ] as const satisfies readonly Event[]; + const postSnapshotEvents = [ + { + type: 'cron.fired', + sessionId, + agentId: 'main', + origin: { + kind: 'cron_job', + jobId: 'cron-load-example', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + prompt: 'Review the newly scheduled report.', + }, + { + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 8, + origin: { + kind: 'cron_job', + jobId: 'cron-load-example', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + }, + { + type: 'thinking.delta', + sessionId, + agentId: 'main', + turnId: 8, + delta: 'Checking the report.', + }, + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId: 8, + toolCallId: 'tool-load-report', + name: 'Read', + args: { path: '/tmp/example.txt' }, + }, + { + type: 'tool.progress', + sessionId, + agentId: 'main', + turnId: 8, + toolCallId: 'tool-load-report', + update: { kind: 'status', text: 'Reading report.' }, + }, + { + type: 'tool.result', + sessionId, + agentId: 'main', + turnId: 8, + toolCallId: 'tool-load-report', + output: 'Report is ready.', + }, + { + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 8, + delta: 'Scheduled review finished.', + }, + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 8, + reason: 'completed', + }, + ] as const satisfies readonly Event[]; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + onSessionEvent: ( + subscribedSessionId: string, + listener: (event: Event) => void, + ) => { + expect(subscribedSessionId).toBe(sessionId); + rawListeners.add(listener); + return () => { + if (!rawListeners.delete(listener)) return; + rawUnsubscribeCount += 1; + }; + }, + resumeSessionWithHandoff: async ( + _input: unknown, + handoff: (resumed: Session) => Promise, + onSnapshotStart?: () => void, + onSnapshotReady?: () => void, + ) => { + for (const event of preSnapshotEvents) { + for (const listener of rawListeners) listener(event); + } + onSnapshotStart?.(); + for (const event of duringSnapshotEvents) { + for (const listener of rawListeners) listener(event); + } + onSnapshotReady?.(); + const setup = handoff(session); + await Promise.resolve(); + for (const event of postSnapshotEvents) { + for (const listener of rawListeners) listener(event); + emit(event); + } + await setup; + return session; + }, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }, + ]), + }), + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + const agentConnection = new AgentSideConnection( + (connection) => new AcpServer(harness, connection), + agentStream, + ); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.loadSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }); + const barrier = collecting.waitForUpdate( + (notification) => + (notification.update._meta as { barrier?: string } | null | undefined)?.barrier === + 'after-cold-load-events', + ); + await agentConnection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [], + _meta: { barrier: 'after-cold-load-events' }, + }, + }); + await barrier; + + expect( + collecting.promptUpdates.map((notification) => notification.update), + ).toEqual([ + expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Earlier question.' }, + }), + expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Earlier answer.' }, + }), + expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Review the newly scheduled report.' }, + }), + expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Review the newly scheduled report.' }, + }), + expect.objectContaining({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'Checking the report.' }, + }), + expect.objectContaining({ + sessionUpdate: 'tool_call', + toolCallId: '8:tool-load-report', + }), + expect.objectContaining({ + sessionUpdate: 'tool_call_update', + toolCallId: '8:tool-load-report', + }), + expect.objectContaining({ + sessionUpdate: 'tool_call_update', + toolCallId: '8:tool-load-report', + status: 'completed', + }), + expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Scheduled review finished.' }, + }), + ]); + expect(rawListeners.size).toBe(0); + expect(rawUnsubscribeCount).toBe(1); + expect(listenerCount()).toBe(1); + }); + + it('pauses an existing live bridge at the active-load snapshot cut', async () => { + const sessionId = 'sess-e2e-active-load-cut'; + const { session, emit, listenerCount } = makeScriptedSession(sessionId, []); + let history: ReadonlyArray = []; + Object.assign(session, { + getResumeState: () => ({ + agents: { + main: { + context: { history, tokenCount: 0 }, + }, + }, + }), + }); + const rawListeners = new Set<(event: Event) => void>(); + let rawUnsubscribeCount = 0; + const publish = (event: Event): void => { + for (const listener of rawListeners) listener(event); + emit(event); + }; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + onSessionEvent: ( + subscribedSessionId: string, + listener: (event: Event) => void, + ) => { + expect(subscribedSessionId).toBe(sessionId); + rawListeners.add(listener); + return () => { + if (rawListeners.delete(listener)) rawUnsubscribeCount += 1; + }; + }, + resumeSessionWithHandoff: async ( + _input: unknown, + handoff: (resumed: Session) => Promise, + onSnapshotStart?: () => void, + onSnapshotReady?: () => void, + ) => { + onSnapshotStart?.(); + publish({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 2, + origin: { kind: 'user', promptId: 'prompt-before-load' }, + } as Event); + publish({ + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 2, + delta: 'Settled before snapshot.', + } as Event); + publish({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 2, + reason: 'completed', + } as Event); + publish({ + type: 'cron.fired', + sessionId, + agentId: 'main', + origin: { + kind: 'cron_job', + jobId: 'cron-active-load-example', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + prompt: 'Review after the active load.', + } as Event); + history = [ + { + role: 'user', + content: [{ type: 'text', text: 'Question before load.' }], + toolCalls: [], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Settled before snapshot.' }], + toolCalls: [], + }, + ]; + onSnapshotReady?.(); + publish({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 3, + origin: { + kind: 'cron_job', + jobId: 'cron-active-load-example', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + } as Event); + publish({ + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 3, + delta: 'Finished after snapshot.', + } as Event); + await handoff(session); + publish({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 3, + reason: 'completed', + } as Event); + return session; + }, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }, + ]), + }), + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + const agentConnection = new AgentSideConnection( + (connection) => new AcpServer(harness, connection), + agentStream, + ); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + collecting.updates.length = 0; + + await client.loadSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }); + const barrier = collecting.waitForUpdate( + (notification) => + (notification.update._meta as { barrier?: string } | null | undefined)?.barrier === + 'after-active-load-cut', + ); + await agentConnection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [], + _meta: { barrier: 'after-active-load-cut' }, + }, + }); + await barrier; + + expect(collecting.promptUpdates.map((notification) => notification.update)).toEqual([ + expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Question before load.' }, + }), + expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Settled before snapshot.' }, + }), + expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Review after the active load.' }, + }), + expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Finished after snapshot.' }, + }), + ]); + expect(rawListeners.size).toBe(0); + expect(rawUnsubscribeCount).toBe(1); + expect(listenerCount()).toBe(1); + }); + + it('restores an existing live bridge when load setup fails after pausing it', async () => { + const sessionId = 'sess-e2e-load-rollback'; + const { session, emit, listenerCount } = makeScriptedSession(sessionId, []); + Object.assign(session, { + getResumeState: () => ({ + agents: { + main: { + context: { history: [], tokenCount: 0 }, + }, + }, + }), + }); + let failConfig = false; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + resumeSessionWithHandoff: async ( + _input: unknown, + handoff: (resumed: Session) => Promise, + onSnapshotStart?: () => void, + onSnapshotReady?: () => void, + ) => { + onSnapshotStart?.(); + onSnapshotReady?.(); + await handoff(session); + return session; + }, + getConfig: async () => { + if (failConfig) { + const config = { + providers: {}, + defaultModel: 'kimi-coder', + }; + Object.defineProperty(config, 'models', { + get() { + emit({ + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 4, + delta: 'Update during failed load.', + } as Event); + throw new Error('model catalog unavailable during load'); + }, + }); + return config; + } + return { + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }, + ]), + }; + }, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + const agentConnection = new AgentSideConnection( + (connection) => new AcpServer(harness, connection), + agentStream, + ); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + failConfig = true; + await expect( + client.loadSession({ sessionId, cwd: '/tmp/work', mcpServers: [] }), + ).rejects.toBeDefined(); + + emit({ + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 5, + delta: 'Update after failed load.', + } as Event); + const barrier = collecting.waitForUpdate( + (notification) => + (notification.update._meta as { barrier?: string } | null | undefined)?.barrier === + 'after-load-rollback', + ); + await agentConnection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [], + _meta: { barrier: 'after-load-rollback' }, + }, + }); + await barrier; + + expect( + collecting.promptUpdates + .filter( + (notification) => + notification.update.sessionUpdate === 'agent_message_chunk', + ) + .map((notification) => + notification.update.sessionUpdate === 'agent_message_chunk' && + notification.update.content.type === 'text' + ? notification.update.content.text + : undefined, + ), + ).toEqual(['Update during failed load.', 'Update after failed load.']); + expect(listenerCount()).toBe(1); + }); + it('holds cold-resume interactions until AcpSession can bridge them', async () => { const sessionId = 'sess-e2e-cold-resume-interactions'; const { session } = makeScriptedSession(sessionId, []); diff --git a/packages/acp-adapter/test/session-load.test.ts b/packages/acp-adapter/test/session-load.test.ts index e7f7b989d4..e4d1548115 100644 --- a/packages/acp-adapter/test/session-load.test.ts +++ b/packages/acp-adapter/test/session-load.test.ts @@ -1,3 +1,11 @@ +/** + * Scenario: an ACP client cold-loads persisted session history. + * Responsibilities: authenticate the request, replay user/assistant/tool state + * in order, and replace only engine-owned autonomous XML with safe user text. + * Wiring: real ACP in-memory transport and AcpServer; the node SDK harness and + * resumed session are the persisted-engine boundary stubs. + * Run: pnpm --filter @moonshot-ai/acp-adapter exec vitest run test/session-load.test.ts + */ import { describe, expect, it } from 'vitest'; import { @@ -16,6 +24,7 @@ import { import { KimiError, ErrorCodes, type Event, type KimiHarness, type Session } from '@moonshot-ai/kimi-code-sdk'; import { AcpServer } from '../src/server'; +import { AcpSession } from '../src/session'; import { AUTHED_STATUS, UNAUTHED_STATUS, makeModelsMap } from './_helpers/harness-stubs'; class CapturingClient implements Client { @@ -64,6 +73,7 @@ function makeSessionWithHistory( sessionId: string, history: ReadonlyArray, statusThinkingEffort?: string, + background: ReadonlyArray = [], ): Session { return { id: sessionId, @@ -75,6 +85,7 @@ function makeSessionWithHistory( agents: { main: { context: { history, tokenCount: 0 }, + background, }, }, }), @@ -93,14 +104,27 @@ function makeHarness( }, ): KimiHarness { const authed = opts.hasUsableToken ?? true; + const resumeSession = async (_input: { id: string }): Promise => { + if (opts.resumeError) throw opts.resumeError; + if (!opts.session) throw new Error('test harness has no session configured'); + return opts.session; + }; return { auth: { status: async () => (authed ? AUTHED_STATUS : UNAUTHED_STATUS), }, - resumeSession: async (_input: { id: string }) => { - if (opts.resumeError) throw opts.resumeError; - if (!opts.session) throw new Error('test harness has no session configured'); - return opts.session; + resumeSession, + resumeSessionWithHandoff: async ( + input: { id: string }, + handoff: (session: Session) => Promise, + onSnapshotStart?: () => void, + onSnapshotReady?: () => void, + ) => { + onSnapshotStart?.(); + const session = await resumeSession(input); + onSnapshotReady?.(); + await handoff(session); + return session; }, // Phase 14: server.loadSession reads these to assemble configOptions // when the resumed session lacks a `modelAlias` (the fixture sessions @@ -178,6 +202,169 @@ describe('AcpServer session/load replay', () => { }); }); + it('replays literal XML from a user-origin message verbatim', async () => { + const sessionId = 'sess-literal-user-xml'; + const history = [ + { + role: 'user', + content: [{ type: 'text', text: 'literal user text' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + ]; + const session = makeSessionWithHistory(sessionId, history); + const harness = makeHarness({ hasUsableToken: true, session }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + void new AgentSideConnection((connection) => new AcpServer(harness, connection), agentStream); + const client = new CapturingClient(); + const clientConn = new ClientSideConnection(() => client, clientStream); + + await clientConn.loadSession({ sessionId, cwd: '/tmp/x', mcpServers: [] }); + + expect(client.historyUpdates.map((notification) => notification.update)).toEqual([ + expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'literal user text' }, + }), + ]); + }); + + it('replaces a persisted task envelope with a safe trigger before replaying the complete turn', async () => { + const sessionId = 'sess-autonomous-task-history'; + const history = [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'internal task control', + }, + ], + toolCalls: [], + origin: { + kind: 'background_task', + taskId: 'example-task', + status: 'lost', + notificationId: 'task:example:lost', + }, + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Checking the recovered task.' }], + toolCalls: [ + { + type: 'function', + id: 'tool-autonomous', + name: 'Read', + arguments: JSON.stringify({ path: '/tmp/example.txt' }), + }, + ], + }, + { + role: 'tool', + toolCallId: 'tool-autonomous', + content: [{ type: 'text', text: 'example contents' }], + toolCalls: [], + }, + ]; + const session = makeSessionWithHistory( + sessionId, + history, + undefined, + [ + { + taskId: 'example-task', + kind: 'process', + description: 'Recovered background task', + status: 'lost', + detached: true, + startedAt: 1, + endedAt: 2, + }, + ], + ); + const harness = makeHarness({ hasUsableToken: true, session }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + void new AgentSideConnection((connection) => new AcpServer(harness, connection), agentStream); + const client = new CapturingClient(); + const clientConn = new ClientSideConnection(() => client, clientStream); + + await clientConn.loadSession({ sessionId, cwd: '/tmp/x', mcpServers: [] }); + + expect(client.historyUpdates.map((notification) => notification.update)).toEqual([ + expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Recovered background task was lost.' }, + }), + expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Checking the recovered task.' }, + }), + expect.objectContaining({ + sessionUpdate: 'tool_call', + toolCallId: '1:tool-autonomous', + }), + expect.objectContaining({ + sessionUpdate: 'tool_call_update', + toolCallId: '1:tool-autonomous', + status: 'completed', + }), + ]); + expect(JSON.stringify(client.historyUpdates)).not.toContain(' { + const sessionId = 'sess-autonomous-cron-history'; + const history = [ + { + role: 'user', + content: [ + { + type: 'text', + text: + '\n' + + '\nReview the scheduled report.\n\n', + }, + ], + toolCalls: [], + origin: { + kind: 'cron_job', + jobId: 'example-job', + cron: '0 9 * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }, + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Autonomous work finished.' }], + toolCalls: [], + }, + ]; + const session = makeSessionWithHistory(sessionId, history); + const harness = makeHarness({ hasUsableToken: true, session }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + void new AgentSideConnection((connection) => new AcpServer(harness, connection), agentStream); + const client = new CapturingClient(); + const clientConn = new ClientSideConnection(() => client, clientStream); + + await clientConn.loadSession({ sessionId, cwd: '/tmp/x', mcpServers: [] }); + + expect(client.historyUpdates.map((notification) => notification.update)).toEqual([ + expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Review the scheduled report.' }, + }), + expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Autonomous work finished.' }, + }), + ]); + expect(JSON.stringify(client.historyUpdates)).not.toContain(' { const sessionId = 'sess-with-tools'; const history = [ @@ -345,3 +532,241 @@ describe('AcpServer session/load replay', () => { expect(thinking.currentValue).toBe('on'); }); }); + +describe('AcpSession history/live cut', () => { + it('waits for an in-flight live update before replaying history and draining paused events', async () => { + const sessionId = 'sess-ordered-replay-cut'; + const listeners = new Set<(event: Event) => void>(); + let history: ReadonlyArray = []; + const session = { + id: sessionId, + cancel: async () => undefined, + prompt: async () => undefined, + onEvent: (listener: (event: Event) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getResumeState: () => ({ + agents: { + main: { + context: { history, tokenCount: 0 }, + }, + }, + }), + } as unknown as Session; + let releaseFirstUpdate!: () => void; + const firstUpdateGate = new Promise((resolve) => { + releaseFirstUpdate = resolve; + }); + let signalFirstUpdate!: () => void; + const firstUpdateStarted = new Promise((resolve) => { + signalFirstUpdate = resolve; + }); + const deliveryOrder: string[] = []; + const conn = { + sessionUpdate: async (notification: SessionNotification) => { + const update = notification.update; + const text = + (update.sessionUpdate === 'agent_message_chunk' || + update.sessionUpdate === 'user_message_chunk') && + update.content.type === 'text' + ? update.content.text + : update.sessionUpdate; + deliveryOrder.push(`start:${text}`); + if (text === 'live before pause') { + signalFirstUpdate(); + await firstUpdateGate; + } + deliveryOrder.push(`end:${text}`); + }, + } as unknown as AgentSideConnection; + const acpSession = new AcpSession(conn, session); + const emit = (event: Event): void => { + for (const listener of listeners) listener(event); + }; + + emit({ + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 1, + delta: 'live before pause', + } as Event); + await firstUpdateStarted; + let pauseSettled = false; + const pause = acpSession.pauseSessionEvents().then(() => { + pauseSettled = true; + }); + emit({ + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 2, + delta: 'live after pause', + } as Event); + await Promise.resolve(); + expect(pauseSettled).toBe(false); + + history = [ + { + role: 'user', + content: [{ type: 'text', text: 'persisted history' }], + toolCalls: [], + }, + ]; + releaseFirstUpdate(); + await pause; + await acpSession.replayHistory(); + + expect(deliveryOrder).toEqual([ + 'start:live before pause', + 'end:live before pause', + 'start:persisted history', + 'end:persisted history', + 'start:live after pause', + 'end:live after pause', + ]); + acpSession.dispose(); + }); + + it('replays the queued safe trigger before the rest of its turn when the historical push fails once', async () => { + const sessionId = 'sess-safe-trigger-fallback-order'; + const listeners = new Set<(event: Event) => void>(); + const session = { + id: sessionId, + cancel: async () => undefined, + prompt: async () => undefined, + onEvent: (listener: (event: Event) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getResumeState: () => ({ + agents: { + main: { + context: { + history: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'internal task control', + }, + ], + toolCalls: [], + origin: { + kind: 'background_task', + taskId: 'example-task', + status: 'lost', + notificationId: 'task:example-task:lost', + }, + }, + { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'tool-recovered', + name: 'Read', + arguments: JSON.stringify({ path: '/tmp/example.txt' }), + }, + ], + }, + { + role: 'tool', + toolCallId: 'tool-recovered', + content: [{ type: 'text', text: 'example contents' }], + toolCalls: [], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Recovered task handled.' }], + toolCalls: [], + }, + ], + tokenCount: 0, + }, + background: [ + { + taskId: 'example-task', + kind: 'process', + description: 'Recovered background task', + status: 'lost', + detached: true, + startedAt: 1, + endedAt: 2, + }, + ], + }, + }, + }), + } as unknown as Session; + const delivered: SessionNotification[] = []; + let safeTriggerAttempts = 0; + const conn = { + sessionUpdate: async (notification: SessionNotification) => { + const update = notification.update; + const isSafeTrigger = + update.sessionUpdate === 'user_message_chunk' && + update.content.type === 'text' && + update.content.text === 'Recovered background task was lost.'; + if (isSafeTrigger) { + safeTriggerAttempts += 1; + if (safeTriggerAttempts === 1) { + throw new Error('transient safe-trigger push failure'); + } + } + delivered.push(notification); + }, + } as unknown as AgentSideConnection; + const acpSession = new AcpSession(conn, session); + const emit = (event: Event): void => { + for (const listener of listeners) listener(event); + }; + + await acpSession.pauseSessionEvents(); + emit({ + type: 'background.task.terminated', + sessionId, + agentId: 'main', + info: { + kind: 'process', + taskId: 'example-task', + description: 'Recovered background task', + status: 'lost', + detached: true, + startedAt: 1, + endedAt: 2, + }, + } as Event); + acpSession.setPausedSessionEventSnapshotBoundary( + acpSession.pausedSessionEventCount(), + ); + + await acpSession.replayHistory(); + + expect(safeTriggerAttempts).toBe(2); + expect(delivered.map((notification) => notification.update)).toEqual([ + expect.objectContaining({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'Recovered background task was lost.' }, + }), + expect.objectContaining({ + sessionUpdate: 'tool_call', + toolCallId: '1:tool-recovered', + }), + expect.objectContaining({ + sessionUpdate: 'tool_call_update', + toolCallId: '1:tool-recovered', + status: 'completed', + }), + expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Recovered task handled.' }, + }), + ]); + expect(JSON.stringify(delivered)).not.toContain('; + settled(): Promise; hasPendingRequests(): boolean; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 69a67fd771..b69b557473 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -25,7 +25,15 @@ * compacts and re-enqueues it — so the loop only learns caught-or-not, while * an unclaimed or uncaught error fails the turn. Emits `turn.*` / delta * events through `event`, persists loop events through `contextMemory`, and - * reads the step budget from `config`. The plain-data loop state + * reads the step budget from `config`. + * + * Quiescence acquisition synchronously holds later admissions while the + * already-admitted Turn FIFO drains, then blocks pumping until its lease is + * released. `settled` tracks that admitted FIFO rather than held admissions, + * so lifecycle disposal can drain Turns and abort the held receipts instead + * of waiting on work that only the lease release could admit. + * + * The plain-data loop state * (`nextReservedTurnId`, `lastRequestTraceId`, `disposing`) is registered * into `agentState` (`IAgentStateService`) and read/written through it; * `pendingTurns` and `activeTurnJob` stay plain fields because a `TurnJob` @@ -122,6 +130,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private readonly heldAdmissions: HeldAdmission[] = []; private activeTurnJob: TurnJob | undefined; private readonly settleWaiters: Array<() => void> = []; + private admissionHoldDepth = 0; private quiescenceDepth = 0; private activeRequestTrace: LLMRequestTrace | undefined; @@ -190,7 +199,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { void assignment.catch(() => undefined); this.pendingAssignments.set(request, assignment); - if (this.quiescenceDepth > 0) { + if (this.admissionHoldDepth > 0) { this.heldAdmissions.push({ request, options }); } else { this.admit(request, options); @@ -259,14 +268,47 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { tryAcquireQuiescence(): IDisposable | undefined { if (this.disposing) throw abortError('Agent loop disposed'); if (this.activeTurnJob !== undefined || this.hasPendingRequests()) return undefined; + return this.holdQuiescence(); + } + + acquireQuiescence(): Promise { + if (this.disposing) throw abortError('Agent loop disposed'); + this.admissionHoldDepth += 1; + return this.finishAcquireQuiescence(); + } + + private async finishAcquireQuiescence(): Promise { + try { + await this.settled(); + if (this.disposing) throw abortError('Agent loop disposed'); + this.quiescenceDepth += 1; + return toDisposable(() => { + this.releaseQuiescence(); + }); + } catch (error) { + this.releaseAdmissionHold(); + throw error; + } + } + + private holdQuiescence(): IDisposable { + this.admissionHoldDepth += 1; this.quiescenceDepth += 1; - return toDisposable(() => this.releaseQuiescence()); + return toDisposable(() => { + this.releaseQuiescence(); + }); } private releaseQuiescence(): void { if (this.quiescenceDepth === 0) return; this.quiescenceDepth -= 1; - if (this.quiescenceDepth > 0 || this.disposing) return; + this.releaseAdmissionHold(); + } + + private releaseAdmissionHold(): void { + if (this.admissionHoldDepth === 0) return; + this.admissionHoldDepth -= 1; + if (this.admissionHoldDepth > 0 || this.quiescenceDepth > 0 || this.disposing) return; this.pumpTurns(); for (const admission of this.heldAdmissions.splice(0)) { if (admission.request.aborted) continue; @@ -313,11 +355,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } settled(): Promise { - if ( - this.activeTurnJob === undefined && - this.pendingTurns.length === 0 && - this.heldAdmissions.length === 0 - ) { + if (this.activeTurnJob === undefined && this.pendingTurns.length === 0) { return Promise.resolve(); } return new Promise((resolve) => { @@ -326,11 +364,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } private maybeSettle(): void { - if ( - this.activeTurnJob !== undefined || - this.pendingTurns.length > 0 || - this.heldAdmissions.length > 0 - ) return; + if (this.activeTurnJob !== undefined || this.pendingTurns.length > 0) return; if (this.settleWaiters.length === 0) return; const waiters = this.settleWaiters.splice(0); for (const resolve of waiters) resolve(); diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index c57bb8d214..1c29665536 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -4,9 +4,10 @@ * Defines the public contract of session lifecycle: the `CreateSessionOptions`, * `ForkSessionOptions`, `CreateChildSessionOptions`, and the * `ISessionLifecycleService` used to create sessions (`create`), look up the - * live ones (`get` / `list`), close them (`close`), archive/restore them, - * fork them (`fork`), and fork-then-tag them as direct children (`createChild`). Announces - * lifecycle transitions through ordered hook slots plus + * live ones (`get` / `list`), close them (`close`), roll back an exact resumed + * handle (`rollbackResume`), archive/restore them, fork them (`fork`), and + * fork-then-tag them as direct children (`createChild`). Announces lifecycle + * transitions through ordered hook slots plus * `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` / * `onDidForkSession`. App-scoped — a single * process-wide instance owns the live session scope tree. Persisted @@ -90,6 +91,7 @@ export interface ISessionLifecycleService { get(sessionId: string): ISessionScopeHandle | undefined; list(): readonly ISessionScopeHandle[]; resume(sessionId: string): Promise; + rollbackResume(handle: ISessionScopeHandle): void; close(sessionId: string): Promise; archive(sessionId: string): Promise; restore(sessionId: string): Promise; diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 3228622afe..8ce5bbc685 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -4,10 +4,11 @@ * Owns the process-wide registry of open Session child scopes, creating them * through the DI scope tree and seeding each with its identity and storage * addressing, running lifecycle hook slots, and tearing them down on - * close/archive — archiving flags the session's `sessionMetadata`, removes - * its `agentLifecycle` agents, restoring clears the archived flag, and - * broadcasts through `event`; session start and resume failures are reported - * through `telemetry`. Each Session scope receives a telemetry view bound to + * close/archive, with an expected-handle rollback path for failed resume + * handoffs — archiving flags the session's `sessionMetadata`, removes its + * `agentLifecycle` agents, restoring clears the archived flag, and broadcasts + * through `event`; session start and resume failures are reported through + * `telemetry`. Each Session scope receives a telemetry view bound to * its session id, while failures before a scope is available use an ephemeral * context view. Creation hooks wrap the session's cron scheduler start, so * edge adapters can subscribe before autonomous work begins and unwind their @@ -340,14 +341,41 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return ready; } + rollbackResume(handle: ISessionScopeHandle): void { + const removed = this.sessions.get(handle.id) === handle; + if (removed) this.sessions.delete(handle.id); + try { + handle.dispose(); + } finally { + if (removed) this._onDidCloseSession.fire({ sessionId: handle.id }); + } + } + async close(sessionId: string): Promise { const handle = this.sessions.get(sessionId); if (handle === undefined) return; await this.announceWillClose({ sessionId, handle, reason: 'exit' }); + if (this.sessions.get(sessionId) !== handle) { + this.rollbackResume(handle); + return; + } this.sessions.delete(sessionId); - await this.drainAgents(handle); - handle.dispose(); + const errors: unknown[] = []; + try { + await this.drainAgents(handle); + } catch (error) { + errors.push(error); + } + try { + handle.dispose(); + } catch (error) { + errors.push(error); + } this._onDidCloseSession.fire({ sessionId }); + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError(errors, `Failed to close session "${sessionId}"`); + } } async archive(sessionId: string): Promise { diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 783a8b49cb..133552dbe5 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -575,6 +575,100 @@ describe('Agent loop', () => { await expect(resumed.result).resolves.toMatchObject({ type: 'completed' }); }); + it('holds new admissions while admitted turns drain into quiescence', async () => { + const started: number[] = []; + const subscription = ctx.get(IEventBus).subscribe('turn.started', (event) => { + started.push(event.turnId); + }); + let activeStarted!: () => void; + const activeDidStart = new Promise((resolve) => { + activeStarted = resolve; + }); + let releaseActive!: () => void; + const activeCanFinish = new Promise((resolve) => { + releaseActive = resolve; + }); + const hook = loop.hooks.onWillBeginStep.register( + 'test-acquire-quiescence', + async (_hookCtx, next) => { + activeStarted(); + await activeCanFinish; + await next(); + }, + ); + ctx.mockNextResponse({ type: 'text', text: 'active complete' }); + ctx.mockNextResponse({ type: 'text', text: 'queued complete' }); + ctx.mockNextResponse({ type: 'text', text: 'held complete' }); + + const active = (await loop.enqueue(nextTurnMessage('active')).assigned).turn; + await activeDidStart; + const queued = (await loop.enqueue(nextTurnMessage('queued')).assigned).turn; + const acquiring = loop.acquireQuiescence(); + const held = loop.enqueue(nextTurnMessage('held during drain')); + let heldAssigned = false; + void held.assigned.then(() => { + heldAssigned = true; + }); + + await Promise.resolve(); + expect(started).toEqual([0]); + expect(heldAssigned).toBe(false); + + releaseActive(); + const [lease] = await Promise.all([acquiring, active.result, queued.result]); + + expect(started).toEqual([0, 1]); + expect(heldAssigned).toBe(false); + expect(loop.status()).toMatchObject({ state: 'idle', hasPendingRequests: true }); + + hook.dispose(); + lease.dispose(); + const resumed = (await held.assigned).turn; + await expect(resumed.result).resolves.toMatchObject({ type: 'completed' }); + expect(started).toEqual([0, 1, 2]); + subscription.dispose(); + }); + + it('rejects a draining acquisition and its held admissions when disposed', async () => { + let activeStarted!: () => void; + const activeDidStart = new Promise((resolve) => { + activeStarted = resolve; + }); + loop.hooks.onWillBeginStep.register( + 'test-dispose-acquire-quiescence', + async (hookContext, next) => { + activeStarted(); + await new Promise((_, reject) => { + if (hookContext.signal.aborted) { + reject(hookContext.signal.reason); + return; + } + hookContext.signal.addEventListener( + 'abort', + () => { + reject(hookContext.signal.reason); + }, + { once: true }, + ); + }); + await next(); + }, + ); + + const active = (await loop.enqueue(nextTurnMessage('active')).assigned).turn; + await activeDidStart; + const acquiring = loop.acquireQuiescence(); + const held = loop.enqueue(nextTurnMessage('held during disposal')); + const acquisitionRejected = expect(acquiring).rejects.toBeDefined(); + const assignmentRejected = expect(held.assigned).rejects.toBeDefined(); + + (loop as IAgentLoopService & { dispose(): void }).dispose(); + + await expect(active.result).resolves.toMatchObject({ type: 'cancelled' }); + await Promise.all([acquisitionRejected, assignmentRejected]); + expect(loop.status()).toMatchObject({ state: 'idle', hasPendingRequests: false }); + }); + it('can abort an admission while quiescence holds it', async () => { const lease = loop.tryAcquireQuiescence(); expect(lease).toBeDefined(); diff --git a/packages/agent-core-v2/test/agent/loop/stubs.ts b/packages/agent-core-v2/test/agent/loop/stubs.ts index 8a14046f23..c44da9a5e6 100644 --- a/packages/agent-core-v2/test/agent/loop/stubs.ts +++ b/packages/agent-core-v2/test/agent/loop/stubs.ts @@ -73,6 +73,7 @@ export function stubLoopWithHooks(options: StubLoopOptions = {}): StubLoop { status() { return { state: active !== undefined ? 'running' : 'idle', activeTurnId: active?.id, pendingTurnIds: [], hasPendingRequests: queue.hasPendingRequests() }; }, cancel(turnId, reason) { cancels.push({ turnId, reason }); if (active === undefined || (turnId !== undefined && active.id !== turnId)) return false; active.cancel(reason); return true; }, tryAcquireQuiescence: () => toDisposable(() => {}), + acquireQuiescence: async () => toDisposable(() => {}), hasPendingRequests: () => queue.hasPendingRequests(), registerLoopErrorHandler: errorHandlers.register, settled: () => Promise.resolve(), drainNextBatch(context) { const batch = queue.takeNextBatch(); if (!batch) return undefined; materialize(batch.driver, context); for (const r of batch.merged) materialize(r, context); return batch; }, diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index d3009bfb0d..0f904b393d 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -234,6 +234,10 @@ class FakeLoopService implements IAgentLoopService { return toDisposable(() => {}); } + async acquireQuiescence(): Promise { + return toDisposable(() => {}); + } + hasPendingRequests(): boolean { return false; } diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index f447eed466..0d29c79b8e 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -199,6 +199,7 @@ function stubSessionLifecycle(): ISessionLifecycleService { get: () => undefined, list: () => [], resume: async () => undefined, + rollbackResume: () => {}, close: async () => {}, archive: async () => {}, restore: async () => undefined, diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 7cfdab0be9..e0a1953ce1 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -916,6 +916,7 @@ function registerSessionExportServices( get: () => options.lifecycleHandle, list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle]), resume: async () => options.lifecycleHandle, + rollbackResume: () => {}, close: async () => {}, archive: async () => {}, restore: async () => options.lifecycleHandle, diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 9330666bd8..bbafc22d15 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -526,6 +526,144 @@ describe('SessionLifecycleService', () => { expect(svc.get('s1')).toBeUndefined(); }); + it('rollbackResume disposes the expected session after its close hook rejects', async () => { + registerScopedService( + LifecycleScope.Session, + ISessionExternalHooksService, + RecordingSessionDisposalService, + ScopeActivation.OnScopeCreated, + 'externalHooks', + ); + const svc = build(); + const handle = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const closeError = new Error('close hook failed'); + const closed: string[] = []; + const subscription = svc.onDidCloseSession((event) => closed.push(event.sessionId)); + const hook = svc.hooks.onWillCloseSession.register('test-close-failure', async () => { + throw closeError; + }); + + try { + await expect(svc.close('s1')).rejects.toBe(closeError); + expect(svc.get('s1')).toBe(handle); + + svc.rollbackResume(handle); + + expect(svc.get('s1')).toBeUndefined(); + expect(disposedSessionScopes).toEqual(['s1']); + expect(closed).toEqual(['s1']); + } finally { + hook.dispose(); + subscription.dispose(); + } + }); + + it('close completes observable teardown when agent draining fails', async () => { + registerScopedService( + LifecycleScope.Session, + ISessionExternalHooksService, + RecordingSessionDisposalService, + ScopeActivation.OnScopeCreated, + 'externalHooks', + ); + const drainError = new Error('agent drain failed'); + const agent = { + id: MAIN_AGENT_ID, + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected agent service access'); + }, + }, + dispose: () => {}, + } as IAgentScopeHandle; + const remove = vi.fn(() => Promise.reject(drainError)); + const svc = build([ + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + list: () => [agent], + remove, + }), + ]); + const closed: string[] = []; + const subscription = svc.onDidCloseSession((event) => closed.push(event.sessionId)); + + try { + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + await expect(svc.close('s1')).rejects.toBe(drainError); + + expect(remove).toHaveBeenCalledWith(MAIN_AGENT_ID); + expect(svc.get('s1')).toBeUndefined(); + expect(disposedSessionScopes).toEqual(['s1']); + expect(closed).toEqual(['s1']); + } finally { + subscription.dispose(); + } + }); + + it('rollbackResume is idempotent for the same session handle', async () => { + registerScopedService( + LifecycleScope.Session, + ISessionExternalHooksService, + RecordingSessionDisposalService, + ScopeActivation.OnScopeCreated, + 'externalHooks', + ); + const svc = build(); + const handle = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const closed: string[] = []; + const subscription = svc.onDidCloseSession((event) => closed.push(event.sessionId)); + + try { + svc.rollbackResume(handle); + svc.rollbackResume(handle); + + expect(svc.get('s1')).toBeUndefined(); + expect(disposedSessionScopes).toEqual(['s1']); + expect(closed).toEqual(['s1']); + } finally { + subscription.dispose(); + } + }); + + it('close leaves a replacement session installed by its hook live', async () => { + registerScopedService( + LifecycleScope.Session, + ISessionExternalHooksService, + RecordingSessionDisposalService, + ScopeActivation.OnScopeCreated, + 'externalHooks', + ); + const svc = build(); + const stale = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + let replacement: typeof stale | undefined; + const closed: string[] = []; + const subscription = svc.onDidCloseSession((event) => closed.push(event.sessionId)); + const hook = svc.hooks.onWillCloseSession.register( + 'test-concurrent-replacement', + async (event, next) => { + if (event.handle === stale) { + replacement = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + } + await next(); + }, + ); + + try { + await svc.close('s1'); + + expect(replacement).toBeDefined(); + expect(svc.get('s1')).toBe(replacement); + expect(svc.list()).toEqual([replacement]); + expect(disposedSessionScopes).toEqual(['s1']); + expect(closed).toEqual([]); + } finally { + hook.dispose(); + subscription.dispose(); + } + }); + it('create seeds identity and materializes metadata', async () => { const svc = build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 52f8c6c4fb..24ab2917d1 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -70,6 +70,7 @@ export class ContextMemory { appendUserMessage( content: readonly ContentPart[], origin: PromptOrigin = USER_PROMPT_ORIGIN, + deferredInputId?: string, ): void { if (content.length === 0) return; // Prompt ingestion (server upload/base64 route, TUI paste, ACP) annotates @@ -86,12 +87,15 @@ export class ContextMemory { this.appendSystemReminder(caption, { kind: 'injection', variant: 'image_compression' }); } if (parts.length === 0) return; - this.appendMessage({ - role: 'user', - content: parts, - toolCalls: [], - origin, - }); + this.appendMessage( + { + role: 'user', + content: parts, + toolCalls: [], + origin, + }, + deferredInputId, + ); } appendSystemReminder(content: string, origin: PromptOrigin): void { @@ -761,10 +765,11 @@ export class ContextMemory { } } - appendMessage(message: ContextMessage): void { + appendMessage(message: ContextMessage, deferredInputId?: string): void { this.agent.records.logRecord({ type: 'context.append_message', message, + deferredInputId, }); if (this.hasOpenToolExchange()) { this.deferredMessages.push(message); diff --git a/packages/agent-core/src/agent/records/blobref.ts b/packages/agent-core/src/agent/records/blobref.ts index 997b695f1e..cacdebda0a 100644 --- a/packages/agent-core/src/agent/records/blobref.ts +++ b/packages/agent-core/src/agent/records/blobref.ts @@ -37,7 +37,8 @@ export class BlobStore { async offload(record: AgentRecord): Promise { switch (record.type) { case 'turn.prompt': - case 'turn.steer': { + case 'turn.steer': + case 'turn.defer': { const input = await this.offloadParts(record.input); return input === record.input ? record : { ...record, input }; } @@ -82,6 +83,7 @@ export class BlobStore { switch (record.type) { case 'turn.prompt': case 'turn.steer': + case 'turn.defer': await this.rehydrateParts(record.input); break; case 'context.append_message': diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index 29511a738a..3c7ec7a4b3 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -42,6 +42,12 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { case 'turn.steer': agent.turn.restoreSteer(input.input, input.origin); return; + case 'turn.defer': + agent.turn.restoreDeferredInput(input.id, input.input, input.origin); + return; + case 'turn.defer.consume': + agent.turn.restoreDeferredInputConsumed(input.id); + return; case 'turn.cancel': agent.turn.cancel(input.turnId); return; @@ -85,7 +91,8 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { agent.swarmMode.exit(); return; case 'context.append_message': - agent.context.appendMessage(input.message); + agent.turn.observeRestoredContextMessage(input.message, input.deferredInputId); + agent.context.appendMessage(input.message, input.deferredInputId); return; case 'context.append_loop_event': agent.context.appendLoopEvent(input.event); @@ -289,7 +296,12 @@ export class AgentRecords { protocol_version: AGENT_WIRE_PROTOCOL_VERSION, }; } - replayedRecords?.push(migratedRecord); + replayedRecords?.push( + shouldRewrite && this.agent.blobStore !== undefined + ? structuredClone(migratedRecord) + : migratedRecord, + ); + await this.agent.blobStore?.rehydrate(migratedRecord); if (this.restore(migratedRecord)) { completed = false; break; diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index e9c1e1b240..a02c98ff36 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -49,6 +49,21 @@ export interface AgentRecordEvents { input: readonly ContentPart[]; origin: PromptOrigin; }; + /** + * Producer input accepted while a resume handoff holds turn starts. + * + * The matching consume record is written only after the input enters + * context. Until then, replay treats this as durable pending work so a + * failed cold handoff can be retried without launching work after failure. + */ + 'turn.defer': { + id: string; + input: readonly ContentPart[]; + origin: PromptOrigin; + }; + 'turn.defer.consume': { + id: string; + }; 'turn.cancel': { turnId?: number }; 'config.update': AgentConfigUpdateData; @@ -99,7 +114,15 @@ export interface AgentRecordEvents { 'full_compaction.complete': {}; 'micro_compaction.apply': { cutoff: number }; - 'context.append_message': { message: ContextMessage }; + 'context.append_message': { + message: ContextMessage; + /** + * Stable identity for a turn input durably deferred by a resume handoff. + * Kept on the record rather than the message so it never reaches model or + * UI projections. + */ + deferredInputId?: string; + }; 'context.append_loop_event': { event: LoopRecordedEvent }; 'context.update_token_count': { tokenCount: number }; 'context.clear': {}; diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index ccdeed9399..8a0bd7f232 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -1,4 +1,5 @@ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; import { createControlledPromise, type ControlledPromise } from '@antfu/utils'; import { @@ -39,7 +40,11 @@ import type { AgentEvent, TurnEndedEvent, TurnEndReason } from '../../rpc'; import type { TelemetryPropertyValue } from '../../telemetry'; import { gateImageFormatParts } from '../../tools/support/image-compress'; import { abortable, isUserCancellation, userCancellationReason } from '../../utils/abort'; -import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context'; +import { + USER_PROMPT_ORIGIN, + type ContextMessage, + type PromptOrigin, +} from '../context'; import { captureMediaStripSnapshot, stripMediaPartsBySnapshot, @@ -61,8 +66,17 @@ interface ActiveTurn { interface BufferedSteer { readonly input: readonly ContentPart[]; readonly origin: PromptOrigin; + readonly deferredId?: string; + readonly inputAlreadyInContext?: boolean; } +type DeferredInputState = Pick< + BufferedSteer, + 'deferredId' | 'inputAlreadyInContext' +>; + +type TurnStartGateRelease = (discardBuffered?: boolean) => void; + export interface TurnEndResult { readonly event: TurnEndedEvent; readonly stopReason?: LoopTurnStopReason; @@ -138,6 +152,18 @@ const GOAL_STEP_CAP_CONTINUATION_PROMPT = [ export class TurnFlow { private steerBuffer: BufferedSteer[] = []; + /** + * Live inputs received while a resume handoff owns the turn-start gate. + * + * This must stay separate from `steerBuffer`: replay uses that buffer for + * historical `turn.steer` records and deliberately clears it in + * `finishResume()`. A background/cron completion that arrives during a cold + * resume is live input and must survive that cleanup until the handoff has + * installed its consumer. + */ + private gatedTurnBuffer: BufferedSteer[] = []; + private readonly restoredDeferredInputs = new Map(); + private turnStartGateCount = 0; private turnId = -1; private activeTurn: 'resuming' | ActiveTurn | null = null; private readonly toolCallStartedAt = new Map< @@ -182,6 +208,20 @@ export class TurnFlow { steer(input: readonly ContentPart[], origin: PromptOrigin = USER_PROMPT_ORIGIN): number | null { // Same format gate as prompt() — steer input enters the history too. const gated = gateImageFormatParts(input); + // A resume handoff gate takes precedence over replay's `resuming` marker: + // input arriving from a live producer during cold restore must not be mixed + // into the historical replay buffer that `finishResume()` clears. + if (this.turnStartGateCount > 0) { + const deferredId = randomUUID(); + this.agent.records.logRecord({ + type: 'turn.defer', + id: deferredId, + input: gated, + origin, + }); + this.gatedTurnBuffer.push({ input: gated, origin, deferredId }); + return null; + } this.agent.records.logRecord({ type: 'turn.steer', input: gated, @@ -202,7 +242,16 @@ export class TurnFlow { return this.prompt([], { kind: 'retry', trigger }); } - private launch(input: readonly ContentPart[], origin: PromptOrigin): number | null { + private launch( + input: readonly ContentPart[], + origin: PromptOrigin, + deferred: DeferredInputState = {}, + ): number | null { + if (this.turnStartGateCount > 0) { + this.gatedTurnBuffer.push({ input, origin, ...deferred }); + return null; + } + if (this.activeTurn) { this.agent.emitEvent({ type: 'error', @@ -223,7 +272,7 @@ export class TurnFlow { // rather than getting stuck "running". (Auto compaction runs inside an active // turn, so the `activeTurn` check above already covers it.) if (this.agent.fullCompaction.isCompacting) { - this.steerBuffer.push({ input, origin }); + this.steerBuffer.push({ input, origin, ...deferred }); return null; } @@ -232,7 +281,13 @@ export class TurnFlow { // start/end pair per continuation turn rather than one mega-turn. const turnId = this.allocateTurnId(); const controller = new AbortController(); - const promise = this.turnWorker(turnId, input, origin, controller.signal); + const promise = this.turnWorker( + turnId, + input, + origin, + controller.signal, + deferred, + ); const firstRequest = createControlledPromise(); this.activeTurn = { turnId, @@ -261,6 +316,43 @@ export class TurnFlow { this.activeTurn = 'resuming'; } + /** + * Atomically prevent another turn from starting, then wait until the current + * live turn settles. Registering the gate before the wait is essential: + * producer input arriving during that window belongs to a later turn, not the + * active turn's steer buffer. The returned idempotent release function + * replays inputs buffered while the gate was held. + */ + async acquireTurnStartGate(): Promise { + const release = this.holdTurnStarts(); + while (this.activeTurn !== null && this.activeTurn !== 'resuming') { + await this.activeTurn.promise.catch(() => undefined); + } + return release; + } + + /** + * Synchronously hold new turn starts. An already-active turn is allowed to + * finish; producer input received meanwhile stays in `gatedTurnBuffer`. + * Used by Session when a cold-resumed main agent materializes underneath a + * gate acquired before replay began. + */ + holdTurnStarts(): TurnStartGateRelease { + this.turnStartGateCount += 1; + let released = false; + return (discardBuffered = false): void => { + if (released) return; + released = true; + if (discardBuffered) { + this.gatedTurnBuffer.length = 0; + } + this.turnStartGateCount -= 1; + if (this.turnStartGateCount === 0 && !discardBuffered) { + this.flushGatedTurnBuffer(); + } + }; + } + /** * Raise the turn counter to cover a turnId observed in a replayed loop event. * This is the authoritative source of the restored counter: every turn that @@ -285,6 +377,48 @@ export class TurnFlow { this.activeTurn = 'resuming'; } + restoreDeferredInput( + id: string, + input: readonly ContentPart[], + origin: PromptOrigin, + ): void { + this.restoredDeferredInputs.set(id, { input, origin, deferredId: id }); + } + + restoreDeferredInputConsumed(id: string): void { + this.restoredDeferredInputs.delete(id); + } + + observeRestoredContextMessage( + message: ContextMessage, + deferredInputId?: string, + ): void { + if (message.role !== 'user') return; + if (deferredInputId !== undefined) { + const input = this.restoredDeferredInputs.get(deferredInputId); + if (input !== undefined) { + this.restoredDeferredInputs.set(deferredInputId, { + ...input, + inputAlreadyInContext: true, + }); + return; + } + } + for (const [id, input] of this.restoredDeferredInputs) { + if ( + input.inputAlreadyInContext !== true && + isDeepStrictEqual(message.origin, input.origin) && + isDeepStrictEqual(message.content, input.input) + ) { + this.restoredDeferredInputs.set(id, { + ...input, + inputAlreadyInContext: true, + }); + return; + } + } + } + cancel(turnId?: number, reason?: unknown): void { this.agent.records.logRecord({ type: 'turn.cancel', turnId }); if (turnId !== undefined && turnId !== this.currentId) { @@ -356,10 +490,14 @@ export class TurnFlow { // Steer flushes happen at sites that cannot await an upload, so any // prompt-attached local video is degraded to an always-safe `