diff --git a/.changeset/stream-agent-initiated-acp-turns.md b/.changeset/stream-agent-initiated-acp-turns.md new file mode 100644 index 0000000000..e621626d1f --- /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 return retryable errors instead of losing prompts during session handoff. 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 a9ef407bee..11ff3cb750 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,12 +206,29 @@ 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(); 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 @@ -222,6 +245,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 +328,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 +382,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 +418,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 +432,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 +455,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, @@ -438,28 +497,23 @@ 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 { 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, configOptions } = await this.setupSessionFromExisting({ + cwd: params.cwd, + sessionId, + mcpServers: params.mcpServers, + mode: 'load', + }); + 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 }; } /** @@ -482,14 +536,17 @@ export class AcpServer implements Agent { * 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 }; } /** @@ -501,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 @@ -525,10 +581,13 @@ export class AcpServer implements Agent { acpSession: AcpSession; configOptions: SessionConfigOption[]; }> { + this.assertNotDisposed(); if (!(await harnessIsAuthed(this.harness))) { throw RequestError.authRequired(); } - if (!this.conn) { + this.assertNotDisposed(); + const conn = this.conn; + if (!conn) { throw RequestError.internalError(undefined, 'AcpServer is missing its AgentSideConnection'); } // ACP `cwd` → SDK `workDir` for parity with `newSession`. The @@ -542,72 +601,374 @@ 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(); - let session: Session; + this.assertNotDisposed(); + const initialSessionEvents: Event[] = []; + const settleInitialInteractions = this.prepareSessionInteractionBridge( + params.sessionId, + ); + let releaseInitialSessionEvents = (): void => undefined; + if (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; + } + } + 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; + + 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. + const resumeState = resumedSession.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(resumedSession.id); + let acpSession: AcpSession; + 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); + 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( + conn, + resumedSession, + this.clientCapabilities, + this.makeTelemetryTrack(), + initialModelId, + this.harness, + initialThinkingEffort, + initialSessionEvents, + true, + ); + initialSessionEvents.splice(0); + this.sessions.set(resumedSession.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( + resumedSession, + resumedThinkingEffort, + ); + this.assertNotDisposed(); + acpSession.setInitialConfigState(currentModelId, currentThinkingEffort); + const configOptions = await buildSessionConfigOptions( + this.harness, + currentModelId, + currentThinkingEffort, + 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: resumedSession, acpSession, configOptions }; + }; + 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, - }); - } catch (err) { - // 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 ( + 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); + // 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 && + 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 err; + throw error; } - // 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, + } + + 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 +1411,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..de279bcf1b 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,32 @@ 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 snapshotQueuedSessionEventCount = 0; + private drainingSessionEvents: Promise | undefined; + private sessionEventTail: Promise = Promise.resolve(); + 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 +283,27 @@ export class AcpSession { * Defaults to `'off'` when absent. */ initialThinkingEffort?: string, + /** + * Live events captured for a known session id while a cold + * `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'; + 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 +314,42 @@ 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; + } + void this.enqueueSessionEvent(event); + }); + if (!deferInitialSessionEvents) { + void this.flushInitialSessionEvents(); + } + interactionHandlersBySession.set(this, { + approval: approvalHandler, + question: questionHandler, + }); + } catch (error) { + this.releaseOwnedRegistrations(); + throw error; } } @@ -269,6 +386,175 @@ 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; + this.snapshotQueuedSessionEventCount = 0; + 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 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. + * 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. + */ + 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; + 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; + } + } + + 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; + } + /** * Forward an ACP `session/cancel` notification to the underlying SDK * session. The SDK's `cancel()` is idempotent at the RPC layer, so @@ -569,7 +855,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 @@ -585,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 - * `runPromptBody` 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) { @@ -605,7 +904,7 @@ export class AcpSession { agentId, knownAgents: resumeState.agents ? Object.keys(resumeState.agents) : [], }); - return; + return replayedAutonomousTriggers; } let turnId = 0; @@ -613,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; @@ -625,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; } /** @@ -653,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({ @@ -668,7 +1064,8 @@ export class AcpSession { }); } } - return; + return undefined; + } case 'assistant': { ctx.beginAssistantTurn(); const turnId = ctx.getTurnId(); @@ -679,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; @@ -688,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) { @@ -696,7 +1093,7 @@ export class AcpSession { sessionId, toolCallId: rawToolCallId, }); - return; + return undefined; } const isError = message.isError === true; await conn.sessionUpdate({ @@ -708,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; } } @@ -767,6 +1164,200 @@ 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 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) { + await this.emitAgentInitiatedUserMessage(text, 'background task'); + } + return; + } + if (event.type === 'cron.fired') { + await 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') { + await 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') { + await 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)) { + await 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); + await 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) { + 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), + }); + }); + } + } + 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); + await 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); + } + await 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; + await 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') { + await 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 async emitAgentInitiatedUserMessage( + text: string, + source: string, + ): Promise { + if (text.length === 0) return; + await 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 +1373,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 +1385,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 +1419,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 +1430,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 +1450,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 +1576,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 +2117,228 @@ 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 + ); +} + +/** + * 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 + * 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`; + return taskStatusDisplayText(subject, info.status); +} + +function taskStatusDisplayText( + subject: string, + status: string | undefined, +): string | undefined { + switch (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.`; + case undefined: + return undefined; + } +} + /** * 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..5d1cf3fcb0 --- /dev/null +++ b/packages/acp-adapter/test/_helpers/real-engine-rig.ts @@ -0,0 +1,470 @@ +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 = { + 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'; +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[], + 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[], + beforeReply?: (request: ModelRequest, index: number) => Promise | void, + ): 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, beforeReply, 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 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; + } + await this.beforeReply?.(modelRequest, index); + respondSse(response, reply, index + 1); + } 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; + }>(); + + 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'); + } + + 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); + } + await this.onSessionUpdate?.(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 homeDir: string; + readonly modelRequests: readonly ModelRequest[]; + readonly session: Session; + readonly workDir: string; + closeRuntime(): Promise; + close(): Promise; +} + +export async function createRealEngineRig(options: { + readonly engine: Engine; + readonly homeDir: string; + 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, + options.beforeModelReply, + ); + let harness: KimiHarness | undefined; + let clientToAgent: TransformStream | undefined; + let agentToClient: TransformStream | undefined; + let client: ClientSideConnection | undefined; + let serverRun: Promise | undefined; + 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`, + 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(options.onSessionUpdate); + client = new ClientSideConnection(() => collecting, clientStream); + 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 ${sessionId}`); + } + + return { + client, + collecting, + harness, + homeDir: options.homeDir, + modelRequests: modelServer.requests, + session, + workDir: options.workDir, + closeRuntime, + close() { + return cleanup(); + }, + }; + } 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..f076691b4d --- /dev/null +++ b/packages/acp-adapter/test/agent-initiated-engine.e2e.test.ts @@ -0,0 +1,773 @@ +/** + * 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, readFile, 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 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 = new Set(); +const rigCreations: Array> = []; +const environmentRestorers: Array<() => void> = []; +const testProcesses = new Map(); +const testGateReleasers: Array<() => void> = []; + +afterEach(async () => { + 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.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) => { + 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 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(' { + 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 trackRig(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.' }, + ], + })); + 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/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..4fe9ab6b46 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,1865 @@ 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(); + void 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); + + await collecting.waitForUpdate( + (notification) => + notification.update.sessionUpdate === 'agent_message_chunk' && + notification.update.content.type === 'text' && + notification.update.content.text === 'Scheduled review finished.', + ); + + // 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('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, []); + 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-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(' 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) { @@ -227,7 +347,7 @@ describe('AcpServer session/prompt', () => { expect(unsubCount).toBe(1); }); - it('rejects prompt when the SDK emits a turn.agent_busy error event', async () => { + it('rejects prompt when session resume emits turn.agent_busy before a turn starts', async () => { const sessionId = 'sess-busy'; const { session, unsubscribeCount } = makeScriptedSession(sessionId, [ { @@ -235,8 +355,7 @@ describe('AcpServer session/prompt', () => { sessionId, agentId: 'main', code: 'turn.agent_busy', - message: 'Cannot launch a new turn while another turn (ID 0) is active', - details: { turnId: 0 }, + message: 'Cannot launch a new turn while session resume is in progress', retryable: true, } as unknown as Event, ]); @@ -257,6 +376,76 @@ describe('AcpServer session/prompt', () => { expect(unsubscribeCount()).toBe(1); }); + it('admits the next correlated turn after a pre-turn busy rejection', async () => { + const sessionId = 'sess-busy-retry'; + let resolveFirstKicked: ((promptId: string) => void) | undefined; + const firstKicked = new Promise((resolve) => { + resolveFirstKicked = resolve; + }); + let resolveSecondKicked: ((promptId: string) => void) | undefined; + const secondKicked = new Promise((resolve) => { + resolveSecondKicked = resolve; + }); + const controlled = makeControlledAdmissionSession( + sessionId, + (promptId, call) => { + if (call === 1) { + resolveFirstKicked?.(promptId); + } else { + resolveSecondKicked?.(promptId); + } + return new Promise(() => undefined); + }, + ); + const acpSession = directAcpSession(controlled.session); + + const first = acpSession.prompt([textBlock('first')]); + const second = acpSession.prompt([textBlock('second')]); + const firstPromptId = await firstKicked; + controlled.emit({ + type: 'error', + sessionId, + agentId: 'main', + code: 'turn.agent_busy', + message: 'Cannot launch a new turn while session resume is in progress', + retryable: true, + } as unknown as Event); + + await expect(first).rejects.toMatchObject({ code: -32600 }); + const secondPromptId = await secondKicked; + controlled.emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 1, + origin: { kind: 'user', promptId: firstPromptId }, + } as Event); + controlled.emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 1, + reason: 'blocked', + } as Event); + controlled.emit({ + type: 'turn.started', + sessionId, + agentId: 'main', + turnId: 2, + origin: { kind: 'user', promptId: secondPromptId }, + } as Event); + controlled.emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 2, + reason: 'completed', + } as Event); + + await expect(second).resolves.toEqual({ stopReason: 'end_turn' }); + acpSession.dispose(); + }); + it('does not reject an already-started prompt when a later prompt gets busy', async () => { const sessionId = 'sess-busy-active'; const listeners = new Set<(event: Event) => void>(); @@ -276,7 +465,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 +478,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 +585,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; + 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..1afcfdb49b 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -25,7 +25,13 @@ * 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`. + * + * Provides a quiescence lease that drains already-admitted Turns while holding + * later admissions. Settlement excludes held work so lifecycle teardown can + * complete without waiting on work owned by the lease. + * + * 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 +128,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 +197,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 +266,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 +353,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 +362,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/agent/rpc/core-api.ts b/packages/agent-core-v2/src/agent/rpc/core-api.ts index 0b6668471e..60384a9e8d 100644 --- a/packages/agent-core-v2/src/agent/rpc/core-api.ts +++ b/packages/agent-core-v2/src/agent/rpc/core-api.ts @@ -121,6 +121,7 @@ export interface SessionSummary { export interface PromptPayload { readonly input: readonly ContentPart[]; + readonly promptId?: string; readonly disabledTools?: readonly string[]; } export interface RunShellCommandPayload { @@ -207,6 +208,7 @@ export interface SkillSummary { export interface ActivateSkillPayload { readonly name: string; readonly args?: string | undefined; + readonly activationId?: string; } export interface ActivatePluginCommandPayload { diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index bb7ffef1d4..dc58468f4c 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -100,7 +100,8 @@ export class AgentRPCService implements IAgentRPCService { role: 'user', content: [...payload.input], toolCalls: [], - origin: { kind: 'user' }, + origin: { kind: 'user', promptId: payload.promptId }, + id: payload.promptId, } }); if (handle.state === 'pending') return undefined; const turn = await handle.launched; diff --git a/packages/agent-core-v2/src/agent/skill/skill.ts b/packages/agent-core-v2/src/agent/skill/skill.ts index 82c282a0e7..ac88859c21 100644 --- a/packages/agent-core-v2/src/agent/skill/skill.ts +++ b/packages/agent-core-v2/src/agent/skill/skill.ts @@ -5,6 +5,7 @@ import type { Turn } from '#/agent/loop/loop'; export interface SkillActivationInput { readonly name: string; readonly args?: string; + readonly activationId?: string; } export interface IAgentSkillService { diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index e16e0971b7..6507eb5226 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -74,7 +74,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService const turn = await 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-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index 2c196b6b91..2576d33c86 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -2,12 +2,15 @@ * `sessionIndex` domain (L2) — session index contract. * * `ISessionIndex` is a domain-specific persistence Store: a backend-neutral - * query facade over the set of persisted sessions (open or closed). It - * enumerates sessions and derives session identity (`workspaceId`), returning - * data (`SessionSummary`) or counts — never filesystem paths or live handles. - * Writes (create / archive) live in `sessionLifecycle` / `session`; the index - * is a read model. Backends are deployment-specific (local filesystem today; - * database / query store on a server). + * query facade over the set of persisted sessions (open or closed), plus + * invalidation of a derived summary after its expected authoritative session + * directory is rolled back. It enumerates sessions and derives session + * identity (`workspaceId`), returning data (`SessionSummary`) or counts — + * never filesystem paths or live handles. Authoritative writes (create / + * archive) live in `sessionLifecycle` / `session`; invalidation may only evict + * a matching derived value, never delete authoritative session state. Backends + * are deployment-specific (local filesystem today; database / query store on + * a server). App-scoped. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -53,6 +56,7 @@ export interface ISessionIndex { /** List persisted sessions, optionally filtered by a set of workspace ids. */ list(query: SessionListQuery): Promise>; get(id: string): Promise; + invalidate(id: string, expectedWorkspaceId: string): Promise; /** Count non-archived sessions across the given set of workspace ids. */ countActive(workspaceIds: readonly string[]): Promise; } diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index b58f9ed590..4fabcebdfd 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -27,9 +27,13 @@ * re-parsing `state.json` on every call. Listing still enumerates the directory * (a cheap `readdir`) to discover `(workspaceId, sessionId)` pairs, but each * summary is resolved through the read model — falling back to a disk read + - * backfill on a cold miss. Writes (create / archive / metadata update) keep the - * read model warm via `SessionMetadata`; new sessions that have not been - * mirrored yet are simply a cold miss and backfilled on first read. The legacy + * backfill on a cold miss. Read-model hits remain subordinate to authoritative + * session directories, and rollback invalidation is ownership-aware so stale + * summaries cannot resurrect removed sessions or evict replacements. Writes + * (create / archive / metadata update) keep the read model warm via + * `SessionMetadata`; new + * sessions that have not been mirrored yet are simply a cold miss and + * backfilled on first read. The legacy * N+1 path remains as the flag-off fallback — and as the runtime fallback if * the query store ever reports `storage.locked`: the first lock warns once and * disables the read model for the rest of the process lifetime. (The minidb @@ -144,6 +148,14 @@ export class FileSessionIndex implements ISessionIndex { ); } + async invalidate(id: string, expectedWorkspaceId: string): Promise { + if (!this.readModelEnabled()) return; + await this.withReadModelFallback( + () => this.invalidateFromReadModel(id, expectedWorkspaceId), + () => Promise.resolve(), + ); + } + async countActive(workspaceIds: readonly string[]): Promise { if (!this.readModelEnabled()) return this.countActiveLegacy(workspaceIds); return this.withReadModelFallback( @@ -194,7 +206,18 @@ export class FileSessionIndex implements ISessionIndex { private async getFromReadModel(id: string): Promise { const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, id); - if (isSessionSummaryShape(cached)) return cached; + if (isSessionSummaryShape(cached)) { + if (await this.hasAuthoritativeSession(cached.workspaceId, id)) return cached; + try { + await this.invalidateFromReadModel(id, cached.workspaceId); + } catch (error) { + if (isStorageError(error, StorageErrors.codes.STORAGE_LOCKED)) throw error; + this.log.warn('failed to invalidate stale session summary', { + sessionId: id, + error: String(error), + }); + } + } for (const workspaceId of await this.listWorkspaceIds()) { if (!(await this.hasSession(workspaceId, id))) continue; return this.getCachedSummary(workspaceId, id); @@ -202,6 +225,21 @@ export class FileSessionIndex implements ISessionIndex { return undefined; } + private async invalidateFromReadModel( + id: string, + expectedWorkspaceId: string, + ): Promise { + const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, id); + if ( + isSessionSummaryShape(cached) && + cached.workspaceId !== expectedWorkspaceId + ) { + return; + } + if (await this.hasAuthoritativeSession(expectedWorkspaceId, id)) return; + await this.queryStore.delete(SESSION_COLLECTION, id); + } + private async countActiveFromReadModel(workspaceIds: readonly string[]): Promise { let count = 0; for (const workspaceId of workspaceIds) { @@ -238,7 +276,7 @@ export class FileSessionIndex implements ISessionIndex { sessionId: string, ): Promise { const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, sessionId); - if (isSessionSummaryShape(cached)) return cached; + if (isSessionSummaryShape(cached) && cached.workspaceId === workspaceId) return cached; const summary = await this.readSummary(workspaceId, sessionId); if (summary !== undefined) { // Also overwrites a cache entry that failed the shape check above. @@ -317,6 +355,14 @@ export class FileSessionIndex implements ISessionIndex { return ids.includes(sessionId); } + private async hasAuthoritativeSession( + workspaceId: string, + sessionId: string, + ): Promise { + const ids = await this.storage.list(`${this.sessionsScope}/${workspaceId}`); + return ids.includes(sessionId); + } + private async readSummary( workspaceId: string, sessionId: string, diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index c57bb8d214..aa2028a28a 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -1,17 +1,12 @@ /** * `sessionLifecycle` domain (L6) — creates and tracks sessions at the process root. * - * 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 - * `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` / - * `onDidForkSession`. App-scoped — a single - * process-wide instance owns the live session scope tree. Persisted - * sessions (open or closed) are the `sessionIndex` read model; per-session - * behaviour lives in the Session-scoped domains. + * Defines the App-scoped public contract for creating, discovering, closing, + * archiving, restoring, and forking sessions, including ordered lifecycle + * hooks/events and exact-handle rollback for a failed resume attachment. One + * process-wide instance owns the live Session scope tree. Persisted sessions + * (open or closed) are discovered through the `sessionIndex` read model; + * per-session behaviour lives in the Session-scoped domains. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -90,6 +85,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 accd49cfd9..9717e611d6 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -1,36 +1,16 @@ /** * `sessionLifecycle` domain (L6) — `ISessionLifecycleService` implementation. * - * 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 - * its session id, while failures before a scope is available use an ephemeral - * context view. - * Materializes the session's initial metadata on - * creation by resolving `sessionMetadata`. Bound at App scope. Persisted - * sessions are discovered through the `sessionIndex` read model, and workspace - * roots are remembered through `workspace`. On create / fork the - * session is also appended to the shared `session_index.jsonl` so v1 clients - * (TUI, export) can discover sessions created by the v2 engine; the entry is - * indexed under the registry-resolved workspace id — the same id seeding the - * session's storage scope — so an alias spelling of the workDir cannot split - * the session into a bucket v1 readers never look in. Fork flushes - * live Agent wire journals, normalizes a missing protocol envelope, and - * appends the fork boundary before restoring the target Agent. On - * materialize, the session's metadata, tool policy, and agent-profile catalog - * are awaited before the handle is published — agent-file discovery is local- - * fs and cheap, and a resumed session's first turn must see file-defined - * agent types in the `Agent` tool description; the catalog's `ready` only - * rejects for a fatal explicit-source error, exactly the case that should - * fail fast, and on that failure the half-materialized handle is disposed - * instead of poisoning the session cache (the skill catalog, by contrast, is - * kicked fire-and-forget). The session-level services whose subscriptions - * must exist before the first agent / turn (external hooks, cron, the - * secondary-model startup warning) opt into `OnScopeCreated` activation. + * Owns the process-wide registry of live Session child scopes and implements + * create, resume, fork, close, archive, restore, and failed-resume rollback. + * It seeds session identity and storage context, prepares session catalogs and + * MCP policy, runs lifecycle hooks and cron startup, and tears down Agents and + * scopes. `bootstrap`, `workspace`, and `sessionIndex` provide persisted-session + * addressing and discovery; successful create/fork operations are mirrored to + * the v1 session index. Fresh startup persistence is removed only while it is + * still discardable, while resumed or retained sessions remain recoverable. + * Lifecycle events are published through `event` and `telemetry`. Bound at App + * scope. */ import { randomUUID } from 'node:crypto'; @@ -66,13 +46,15 @@ import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLoca import { IWorkspaceService } from '#/app/workspace/workspace'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2, isError2 } from '#/errors'; -import { createHooks } from '#/hooks'; +import { createHooks, type HookSlot } from '#/hooks'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem, type HostDirEntry } from '#/os/interface/hostFileSystem'; +import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; +import { ISessionCronService } from '#/session/cron/sessionCronService'; import { ISessionMcpService } from '#/session/mcp/sessionMcp'; import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; @@ -106,9 +88,50 @@ type MaterializeSessionOptions = Omit & { readonly workspaceId?: string; }; +interface MaterializedSession { + readonly handle: ISessionScopeHandle; + readonly node: SessionMaterializationNode; + readonly context: ISessionContext; +} + +interface MaterializeSessionPolicy { + readonly cleanupFreshOnFailure: boolean; + readonly requireFreshPath?: boolean; + readonly prepare?: (context: ISessionContext) => Promise; +} + +interface AnnounceCreatedOptions { + readonly prepare?: () => Promise; + readonly commit?: () => Promise; + readonly validate?: () => void; + readonly beforePublish?: () => void; +} + +interface SessionMaterializationNode { + readonly handle: ISessionScopeHandle; + readonly context: ISessionContext; + readonly state: SessionPathState; + globalPrev: SessionMaterializationNode | undefined; + globalNext: SessionMaterializationNode | undefined; + pathPrev: SessionMaterializationNode | undefined; + pathNext: SessionMaterializationNode | undefined; + retired: boolean; +} + +interface SessionPathState { + cleanupOnFailure: boolean; +} + export class SessionLifecycleService extends Disposable implements ISessionLifecycleService { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); + private readonly sessionTails = new Map(); + private readonly pathMutationTails = new Map>(); + private readonly pathTails = new Map(); + private readonly handleNodes = new WeakMap< + ISessionScopeHandle, + SessionMaterializationNode + >(); private readonly _onDidCreateSession = this._register(new Emitter()); readonly onDidCreateSession: Event = this._onDidCreateSession.event; private readonly _onDidCloseSession = this._register(new Emitter()); @@ -117,10 +140,19 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec readonly onDidArchiveSession: Event = this._onDidArchiveSession.event; private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; - readonly hooks = createHooks([ + private readonly lifecycleHooks = createHooks< + SessionLifecycleHooks, + keyof SessionLifecycleHooks + >([ 'onDidCreateSession', 'onWillCloseSession', ]); + readonly hooks = { + onDidCreateSession: withHandledDetachedNext( + this.lifecycleHooks.onDidCreateSession, + ), + onWillCloseSession: this.lifecycleHooks.onWillCloseSession, + }; private readonly resuming = new Map>(); constructor( @@ -144,7 +176,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec async create(opts: CreateSessionOptions): Promise { const sessionId = opts.sessionId ?? createSessionId(); - const handle = await this.materializeSession({ ...opts, sessionId }); + const materialized = await this.materializeSession( + { ...opts, sessionId }, + { cleanupFreshOnFailure: true }, + ); + const { handle } = materialized; try { const main = opts.mainAgentBinding === undefined @@ -160,24 +196,36 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec // Index the session under the workspace id the registry actually resolved // (the same one seeding the session's storage scope), not a recomputed // `encodeWorkDirKey` — with root folding the two can diverge. - await this.appendSessionIndexEntry( - sessionId, - opts.workDir, - handle.accessor.get(ISessionContext).workspaceId, + await this.announceCreated( + { sessionId, handle, source: 'startup' }, + { + commit: async () => { + this.assertCurrentMaterialization(materialized); + await this.commitSessionPersistence(materialized); + this.assertCurrentMaterialization(materialized); + await this.appendSessionIndexEntry( + sessionId, + opts.workDir, + handle.accessor.get(ISessionContext).workspaceId, + ); + }, + validate: () => { + this.assertCurrentMaterialization(materialized); + }, + }, ); + this.assertCurrentMaterialization(materialized); + return handle; } catch (error) { - const sessionDir = handle.accessor.get(ISessionContext).sessionDir; - this.sessions.delete(sessionId); - await this.drainAgents(handle).catch(() => {}); - handle.dispose(); - await this.hostFs.remove(sessionDir).catch(() => {}); + await this.rollbackCreatedSession(materialized, true); throw error; } - await this.announceCreated({ sessionId, handle, source: 'startup' }); - return handle; } - private async materializeSession(opts: MaterializeSessionOptions): Promise { + private async materializeSession( + opts: MaterializeSessionOptions, + policy: MaterializeSessionPolicy, + ): Promise { const workspace = await this.workspaces.createOrTouch(opts.workDir); const workspaceId = opts.workspaceId ?? workspace.id; const sessionScope = this.bootstrap.sessionScope(workspaceId, opts.sessionId); @@ -200,32 +248,88 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); const additionalDirs = [...localWorkspaceDirs.additionalDirs, ...callerAdditionalDirs]; await this.hostEnv.ready; - const handle = createScopedChildHandle( - this.instantiation, - LifecycleScope.Session, - opts.sessionId, - { - extra: [ - ...sessionContextSeed(ctx), - [ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })], - ], - }, - ) as ISessionScopeHandle; - if (additionalDirs.length > 0) { - handle.accessor.get(ISessionWorkspaceContext).setAdditionalDirs(additionalDirs); - } - try { - await handle.accessor.get(ISessionMetadata).ready; - await handle.accessor.get(ISessionToolPolicy).ready; - void handle.accessor.get(ISessionSkillCatalog).ready; - await handle.accessor.get(ISessionAgentProfileCatalog).ready; - await handle.accessor.get(ISessionMcpService).ensureMcpReady(opts.mcpServers); - } catch (error) { - handle.dispose(); - throw error; - } - this.sessions.set(opts.sessionId, handle); - return handle; + return this.withPathMutation(sessionDir, async () => { + const pathTailAtStart = this.pathTails.get(sessionDir); + const sessionDirExists = await this.sessionDirExists(sessionDir); + const sessionDirExisted = pathTailAtStart !== undefined || sessionDirExists; + const initialPathState: SessionPathState = policy.cleanupFreshOnFailure + ? pathTailAtStart?.state ?? { cleanupOnFailure: !sessionDirExisted } + : { cleanupOnFailure: false }; + let handle: ISessionScopeHandle | undefined; + try { + if (policy.requireFreshPath === true && sessionDirExisted) { + throw new Error2( + ErrorCodes.SESSION_ALREADY_EXISTS, + `Session "${opts.sessionId}" already exists`, + ); + } + await policy.prepare?.(ctx); + handle = createScopedChildHandle( + this.instantiation, + LifecycleScope.Session, + opts.sessionId, + { + extra: [ + ...sessionContextSeed(ctx), + [ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })], + ], + }, + ) as ISessionScopeHandle; + if (additionalDirs.length > 0) { + handle.accessor.get(ISessionWorkspaceContext).setAdditionalDirs(additionalDirs); + } + await handle.accessor.get(ISessionMetadata).ready; + await handle.accessor.get(ISessionToolPolicy).ready; + void handle.accessor.get(ISessionSkillCatalog).ready; + await handle.accessor.get(ISessionAgentProfileCatalog).ready; + await handle.accessor.get(ISessionMcpService).ensureMcpReady(opts.mcpServers); + + const globalPrev = this.sessionTails.get(opts.sessionId); + const pathPrev = this.pathTails.get(sessionDir); + const pathState = pathPrev?.state ?? initialPathState; + if (!policy.cleanupFreshOnFailure) pathState.cleanupOnFailure = false; + const node: SessionMaterializationNode = { + handle, + context: ctx, + state: pathState, + globalPrev, + globalNext: undefined, + pathPrev, + pathNext: undefined, + retired: false, + }; + if (globalPrev !== undefined) globalPrev.globalNext = node; + if (pathPrev !== undefined) pathPrev.pathNext = node; + this.sessions.set(opts.sessionId, handle); + this.sessionTails.set(opts.sessionId, node); + this.pathTails.set(sessionDir, node); + this.handleNodes.set(handle, node); + const disposeScope = handle.dispose.bind(handle); + handle.dispose = () => { + this.retireNode(node); + disposeScope(); + }; + return { + handle, + node, + context: ctx, + }; + } catch (error) { + if (handle !== undefined) { + try { + handle.dispose(); + } catch {} + } + if ( + policy.cleanupFreshOnFailure && + initialPathState.cleanupOnFailure && + this.pathTails.get(sessionDir) === undefined + ) { + await this.removeSessionPersistence(ctx); + } + throw error; + } + }); } /** @@ -249,12 +353,222 @@ 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, + options: AnnounceCreatedOptions = {}, + ): Promise { + let terminal: Promise | undefined; + let terminalOpen = true; + let hookFailed = false; + let hookError: unknown; + try { + await this.hooks.onDidCreateSession.run( + event, + () => { + if (!terminalOpen && terminal === undefined) { + const rejected = Promise.reject( + new Error('Session creation hook terminal is already closed'), + ); + void rejected.catch(() => {}); + return rejected; + } + return (terminal ??= (async () => { + await options.prepare?.(); + await event.handle.accessor.get(ISessionCronService).start(); + await options.commit?.(); + })()); + }, + ); + } catch (error) { + hookFailed = true; + hookError = error; + } + terminalOpen = false; + if (terminal === undefined) { + if (hookFailed) throw hookError; + throw new Error('Session creation hooks did not reach the lifecycle terminal'); + } + let terminalFailed = false; + let terminalError: unknown; + try { + await terminal; + } catch (error) { + terminalFailed = true; + terminalError = error; + } + if (hookFailed) throw hookError; + if (terminalFailed) throw terminalError; + options.validate?.(); + options.beforePublish?.(); + options.validate?.(); + const sessionTelemetry = event.handle.accessor.get(ITelemetryService); this._onDidCreateSession.fire(event); - event.handle.accessor - .get(ITelemetryService) - .track2('session_started', { resumed: event.source === 'resume' }); + options.validate?.(); + sessionTelemetry.track2('session_started', { resumed: event.source === 'resume' }); + } + + private async rollbackCreatedSession( + materialized: MaterializedSession, + removePersistence: boolean, + ): Promise { + const { handle, node, context } = materialized; + return this.withPathMutation(context.sessionDir, async () => { + const ownedSession = this.sessionTails.get(handle.id) === node; + const retired = this.retireNode(node); + if (retired) { + await this.drainAgents(handle).catch(() => {}); + try { + handle.dispose(); + } catch {} + } + + if ( + removePersistence && + node.state.cleanupOnFailure && + this.pathTails.get(context.sessionDir) === undefined + ) { + await this.removeSessionPersistence(context); + } + return ownedSession && retired; + }); + } + + private async withPathMutation( + sessionDir: string, + operation: () => Promise, + ): Promise { + const predecessor = this.pathMutationTails.get(sessionDir); + let release!: () => void; + const tail = new Promise((resolve) => { + release = resolve; + }); + this.pathMutationTails.set(sessionDir, tail); + if (predecessor !== undefined) await predecessor; + try { + return await operation(); + } finally { + release(); + if (this.pathMutationTails.get(sessionDir) === tail) { + this.pathMutationTails.delete(sessionDir); + } + } + } + + private async sessionDirExists(sessionDir: string): Promise { + try { + await this.hostFs.stat(sessionDir); + return true; + } catch (error) { + if ( + error instanceof HostFsError && + error.code === OsFsErrors.codes.OS_FS_NOT_FOUND + ) { + return false; + } + throw error; + } + } + + private async removeSessionPersistence(context: ISessionContext): Promise { + try { + await this.hostFs.remove(context.sessionDir); + } catch { + return; + } + await this.index.invalidate(context.sessionId, context.workspaceId).catch(() => {}); + } + + private async commitSessionPersistence( + materialized: MaterializedSession, + ): Promise { + const { node, context } = materialized; + node.state.cleanupOnFailure = false; + await this.withPathMutation(context.sessionDir, async () => {}); + } + + private assertCurrentMaterialization(materialized: MaterializedSession): void { + const { handle, node, context } = materialized; + if ( + node.retired || + this.sessionTails.get(handle.id) !== node || + this.pathTails.get(context.sessionDir) !== node + ) { + throw new Error2( + ErrorCodes.SESSION_NOT_FOUND, + `Session "${handle.id}" is no longer current`, + ); + } + } + + private retireNode(node: SessionMaterializationNode): boolean { + if (node.retired) return false; + node.retired = true; + + const { globalPrev, globalNext, pathPrev, pathNext } = node; + if (globalPrev !== undefined) globalPrev.globalNext = globalNext; + if (globalNext !== undefined) globalNext.globalPrev = globalPrev; + if (this.sessionTails.get(node.handle.id) === node) { + if (globalPrev === undefined) { + this.sessionTails.delete(node.handle.id); + this.sessions.delete(node.handle.id); + } else { + this.sessionTails.set(node.handle.id, globalPrev); + this.sessions.set(node.handle.id, globalPrev.handle); + } + } + + const sessionDir = node.context.sessionDir; + if (pathPrev !== undefined) pathPrev.pathNext = pathNext; + if (pathNext !== undefined) pathNext.pathPrev = pathPrev; + if (this.pathTails.get(sessionDir) === node) { + if (pathPrev === undefined) { + this.pathTails.delete(sessionDir); + } else { + this.pathTails.set(sessionDir, pathPrev); + } + } + + this.handleNodes.delete(node.handle); + node.globalPrev = undefined; + node.globalNext = undefined; + node.pathPrev = undefined; + node.pathNext = undefined; + return true; + } + + private terminalCut(handle: ISessionScopeHandle): SessionMaterializationNode[] { + const node = this.handleNodes.get(handle); + if (node === undefined || node.retired) return []; + const cut: SessionMaterializationNode[] = []; + let candidate: SessionMaterializationNode | undefined = node; + while (candidate !== undefined) { + cut.push(candidate); + candidate = candidate.globalPrev; + } + for (const item of cut) { + item.state.cleanupOnFailure = false; + this.retireNode(item); + } + return cut; + } + + private async disposeNodes( + nodes: readonly SessionMaterializationNode[], + ): Promise { + const errors: unknown[] = []; + for (const node of nodes) { + try { + await this.drainAgents(node.handle); + } catch (error) { + errors.push(error); + } + try { + node.handle.dispose(); + } catch (error) { + errors.push(error); + } + } + return errors; } get(sessionId: string): ISessionScopeHandle | undefined { @@ -292,17 +606,36 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const workDir = summary.cwd ?? workspace?.root; if (workDir === undefined) return undefined; - const handle = await this.materializeSession({ - sessionId, - workDir, - workspaceId: summary.workspaceId, - }); - const agents = handle.accessor.get(IAgentLifecycleService); - if (agents.get(MAIN_AGENT_ID) === undefined) { - await agents.create({ agentId: MAIN_AGENT_ID }); + const materialized = await this.materializeSession( + { + sessionId, + workDir, + workspaceId: summary.workspaceId, + }, + { cleanupFreshOnFailure: false }, + ); + const { handle } = materialized; + try { + const agents = handle.accessor.get(IAgentLifecycleService); + await this.announceCreated( + { sessionId, handle, source: 'resume' }, + { + prepare: async () => { + if (agents.get(MAIN_AGENT_ID) === undefined) { + await agents.create({ agentId: MAIN_AGENT_ID }); + } + }, + validate: () => { + this.assertCurrentMaterialization(materialized); + }, + }, + ); + this.assertCurrentMaterialization(materialized); + return handle; + } catch (error) { + await this.rollbackCreatedSession(materialized, false); + throw error; } - await this.announceCreated({ sessionId, handle, source: 'resume' }); - return handle; } list(): readonly ISessionScopeHandle[] { @@ -313,30 +646,68 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return ready; } + rollbackResume(handle: ISessionScopeHandle): void { + const node = this.handleNodes.get(handle); + if (node === undefined || node.retired) return; + const removed = this.sessionTails.get(handle.id) === node; + const restored = removed && node.globalPrev !== undefined; + this.retireNode(node); + try { + handle.dispose(); + } finally { + if (removed && !restored && this.sessions.get(handle.id) === undefined) { + 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' }); - this.sessions.delete(sessionId); - await this.drainAgents(handle); - handle.dispose(); - this._onDidCloseSession.fire({ sessionId }); + const cut = this.terminalCut(handle); + const errors = await this.disposeNodes(cut); + if (cut.length > 0 && this.sessions.get(sessionId) === undefined) { + 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 { const handle = this.sessions.get(sessionId); if (handle === undefined) return; + const node = this.handleNodes.get(handle); + if (node === undefined) return; const meta = handle.accessor.get(ISessionMetadata); - await meta.setArchived(true); - await this.drainAgents(handle); - this.event.publish({ - type: 'event.session.archived', - payload: { sessionId }, - }); await this.announceWillClose({ sessionId, handle, reason: 'exit' }); - this.sessions.delete(sessionId); - handle.dispose(); - this._onDidArchiveSession.fire({ sessionId }); + const { cut, archived } = await this.withPathMutation( + node.context.sessionDir, + async () => { + if (node.retired) return { cut: [], archived: false }; + let archived = false; + if (this.pathTails.get(node.context.sessionDir) === node) { + await meta.setArchived(true); + archived = + !node.retired && this.pathTails.get(node.context.sessionDir) === node; + } + return { cut: this.terminalCut(handle), archived }; + }, + ); + const errors = await this.disposeNodes(cut); + if (archived && cut.length > 0 && this.sessions.get(sessionId) === undefined) { + this.event.publish({ + type: 'event.session.archived', + payload: { sessionId }, + }); + this._onDidArchiveSession.fire({ sessionId }); + } + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError(errors, `Failed to archive session "${sessionId}"`); + } } async restore(sessionId: string): Promise { @@ -378,8 +749,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec // quiesce: the only requirement is a durable copy point, which // `copyAgentWire`'s flush provides. let targetId: string | undefined; - let target: ISessionScopeHandle | undefined; - let targetSessionDir: string | undefined; + let materializedTarget: MaterializedSession | undefined; try { const workspace = await this.workspaces.get(workspaceId); if (workspace === undefined) { @@ -399,16 +769,25 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec ); } - targetSessionDir = this.bootstrap.sessionDir(workspaceId, targetId); - await this.copySessionFiles( - this.bootstrap.sessionDir(workspaceId, sourceId), - targetSessionDir, + const createdTargetId = targetId; + const materialized = await this.materializeSession( + { + sessionId: createdTargetId, + workDir: workspace.root, + workspaceId, + }, + { + cleanupFreshOnFailure: true, + requireFreshPath: true, + prepare: (context) => + this.copySessionFiles( + this.bootstrap.sessionDir(workspaceId, sourceId), + context.sessionDir, + ), + }, ); - - target = await this.materializeSession({ - sessionId: targetId, - workDir: workspace.root, - }); + materializedTarget = materialized; + const target = materialized.handle; const targetCtx = target.accessor.get(ISessionContext); const targetMeta = target.accessor.get(ISessionMetadata); @@ -446,26 +825,36 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); } - await this.appendSessionIndexEntry(targetId, workspace.root, targetCtx.workspaceId); - this._onDidForkSession.fire({ - sourceSessionId: sourceId, - sessionId: targetId, - handle: target, - }); - await this.announceCreated({ sessionId: targetId, handle: target, source: 'fork' }); + await this.announceCreated( + { sessionId: targetId, handle: target, source: 'fork' }, + { + commit: async () => { + this.assertCurrentMaterialization(materialized); + await this.commitSessionPersistence(materialized); + this.assertCurrentMaterialization(materialized); + await this.appendSessionIndexEntry( + createdTargetId, + workspace.root, + targetCtx.workspaceId, + ); + }, + validate: () => { + this.assertCurrentMaterialization(materialized); + }, + beforePublish: () => { + this._onDidForkSession.fire({ + sourceSessionId: sourceId, + sessionId: createdTargetId, + handle: target, + }); + }, + }, + ); + this.assertCurrentMaterialization(materialized); return target; } catch (error) { - if (targetId !== undefined) { - this.sessions.delete(targetId); - } - if (target !== undefined) { - try { - target.dispose(); - } catch { - } - } - if (targetSessionDir !== undefined) { - await this.hostFs.remove(targetSessionDir).catch(() => {}); + if (materializedTarget !== undefined) { + await this.rollbackCreatedSession(materializedTarget, true); } throw error; } @@ -620,6 +1009,24 @@ registerScopedService( 'sessionLifecycle', ); +function withHandledDetachedNext(slot: HookSlot): HookSlot { + return { + register: (id, handler, options) => + slot.register( + id, + (context, next) => + handler(context, (override) => { + const result = next(override); + void result.catch(() => {}); + return result; + }), + options, + ), + delete: (id) => slot.delete(id), + run: (context, terminal) => slot.run(context, terminal), + }; +} + async function collect(iterable: AsyncIterable): Promise { const items: T[] = []; for await (const item of iterable) items.push(item); 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/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..f70aa44b2f 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -898,6 +898,7 @@ function registerSessionExportServices( _serviceBrand: undefined, list: async () => ({ items: options.summary === undefined ? [] : [options.summary] }), get: async () => options.summary, + invalidate: async () => {}, countActive: async () => (options.summary === undefined || options.summary.archived ? 0 : 1), }); reg.defineInstance(ISessionLifecycleService, { @@ -916,6 +917,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/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index a0b35cceef..74d5e72a5d 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -1,8 +1,17 @@ +/** + * Scenario: legacy and MiniDB-backed discovery of filesystem-persisted sessions. + * Responsibilities: list/get/count authoritative sessions and invalidate only matching stale + * summaries. + * Wiring: real FileStorage/atomic documents; the read-model suite uses real MiniDB, while legacy + * uses its boundary stub. + * Run from the package: + * pnpm exec vitest run test/app/sessionIndex/sessionIndex.test.ts + */ import { promises as fsp } from 'node:fs'; import os from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LifecycleScope, @@ -53,6 +62,7 @@ describe('FileSessionIndex (legacy)', () => { }); afterEach(async () => { + vi.restoreAllMocks(); disposeHost?.(); disposeHost = undefined; await fsp.rm(homeDir, { recursive: true, force: true }); @@ -264,6 +274,7 @@ describe('FileSessionIndex (read model)', () => { }); afterEach(async () => { + vi.restoreAllMocks(); disposeHost?.(); disposeHost = undefined; await fsp.rm(homeDir, { recursive: true, force: true }); @@ -325,12 +336,55 @@ describe('FileSessionIndex (read model)', () => { }); it('get prefers the read model over disk', async () => { + await seedSession('warm', { title: 'on-disk', createdAt: 1, updatedAt: 2 }); const store = build(); await queryStore.put(SESSION_COLLECTION, 'warm', summary('warm', { title: 'cached' })); const got = await store.get('warm'); expect(got?.title).toBe('cached'); }); + it('get invalidates a cached summary whose authoritative directory is missing', async () => { + const store = build(); + await queryStore.put(SESSION_COLLECTION, 'stale', summary('stale', { title: 'cached' })); + + expect(await store.get('stale')).toBeUndefined(); + expect(await queryStore.get(SESSION_COLLECTION, 'stale')).toBeUndefined(); + }); + + it('get does not return a stale summary when best-effort invalidation fails', async () => { + const store = build(); + const cached = summary('stale', { title: 'cached' }); + await queryStore.put(SESSION_COLLECTION, 'stale', cached); + vi.spyOn(queryStore, 'delete').mockRejectedValueOnce(new Error('delete failed')); + + expect(await store.get('stale')).toBeUndefined(); + expect(await queryStore.get(SESSION_COLLECTION, 'stale')).toEqual(cached); + }); + + it('invalidate preserves a summary while its authoritative directory exists', async () => { + await seedSession('active', { title: 'on-disk', createdAt: 1, updatedAt: 2 }); + const store = build(); + const cached = summary('active', { title: 'cached' }); + await queryStore.put(SESSION_COLLECTION, 'active', cached); + + await store.invalidate('active', workspaceId); + + expect(await queryStore.get(SESSION_COLLECTION, 'active')).toEqual(cached); + }); + + it('invalidate preserves a summary owned by a different workspace', async () => { + const store = build(); + const cached = summary('replacement', { + workspaceId: encodeWorkDirKey('/home/user/replacement'), + title: 'replacement', + }); + await queryStore.put(SESSION_COLLECTION, 'replacement', cached); + + await store.invalidate('replacement', workspaceId); + + expect(await queryStore.get(SESSION_COLLECTION, 'replacement')).toEqual(cached); + }); + it('list treats a cache entry missing required fields as a cold miss', async () => { await seedSession('s1', { title: 'on-disk', createdAt: 1, updatedAt: 2 }); const store = build(); 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..1ab7ef8418 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -1,10 +1,19 @@ +/** + * Scenario: session create, resume, replacement, rollback, archive, and fork lifecycle. + * Responsibilities: publish only ready handles and preserve/remove the matching persisted + * generation. + * Wiring: scoped DI host; persistence regressions use real FileStorage/MiniDB, with startup + * seams stubbed. + * Run from the package: + * pnpm exec vitest run test/app/sessionLifecycle/sessionLifecycle.test.ts + */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 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, @@ -16,8 +25,10 @@ import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/ import { Event } from '#/_base/event'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { IEventService } from '#/app/event/event'; import { @@ -35,12 +46,20 @@ import { SessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle import { IAgentActivityView } from '#/agent/activityView/activityView'; import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { SessionMetadata } from '#/session/sessionMetadata/sessionMetadataService'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; +import { FileSessionIndex } from '#/app/sessionIndex/sessionIndexService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; +import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; +import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; +import { MiniDbQueryStore } from '#/persistence/backends/minidb/miniDbQueryStore'; +import { IQueryStore } from '#/persistence/interface/queryStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; @@ -50,8 +69,11 @@ import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { ILogService } from '#/_base/log/log'; import { Error2, ErrorCodes } from '#/errors'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; +import { stubFlag } from '../flag/stubs'; +import { stubLog } from '../../_base/log/stubs'; function bootstrapStub(): IBootstrapService { return { @@ -69,7 +91,9 @@ function bootstrapStub(): IBootstrapService { function tmpBootstrapStub(root: string): IBootstrapService { return { sessionsDir: join(root, 'sessions'), + cacheDir: join(root, 'cache'), homeDir: root, + scope: (name) => name, sessionScope: (workspaceId: string, sessionId: string) => `sessions/${workspaceId}/${sessionId}`, agentScope: (workspaceId: string, sessionId: string, agentId: string) => @@ -238,6 +262,7 @@ function sessionIndexStub(): ISessionIndex { _serviceBrand: undefined, list: () => Promise.resolve({ items: [], total: 0, hasMore: false }), get: () => Promise.resolve(undefined), + invalidate: () => Promise.resolve(), countActive: () => Promise.resolve(0), }; } @@ -259,6 +284,7 @@ function sessionIndexWithSummary( _serviceBrand: undefined, list: () => Promise.resolve({ items: [summary], total: 1, hasMore: false }), get: (id) => Promise.resolve(id === sessionId ? summary : undefined), + invalidate: () => Promise.resolve(), countActive: () => Promise.resolve(1), }; } @@ -275,6 +301,20 @@ function appendLogStoreStub(): IAppendLogStore { }; } +async function readLegacySessionIndex(root: string): Promise[]> { + let raw: string; + try { + raw = await readFile(join(root, 'session_index.jsonl'), 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + return raw + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + function atomicDocumentStoreStub(): IAtomicDocumentStore { return { _serviceBrand: undefined, @@ -323,6 +363,29 @@ function sessionMcpServiceStub( }; } +function pathAwareHostFileSystemStub( + remove: (path: string) => Promise, + pathExists = false, +): IHostFileSystem { + return { + _serviceBrand: undefined, + stat: () => + pathExists + ? Promise.resolve({ + isFile: false, + isDirectory: true, + size: 0, + }) + : Promise.reject( + new HostFsError( + OsFsErrors.codes.OS_FS_NOT_FOUND, + 'test session directory does not exist', + ), + ), + remove, + } as unknown as IHostFileSystem; +} + function agentLifecycleWithMainStub(): IAgentLifecycleService { const main = { id: MAIN_AGENT_ID, @@ -394,6 +457,110 @@ 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); + }), + ); + } +} + +class ThrowingSessionDisposalService + extends Disposable + implements ISessionExternalHooksService +{ + declare readonly _serviceBrand: undefined; + + constructor() { + super(); + this._register({ + dispose: () => { + throw new Error('session scope disposal failed'); + }, + }); + } +} + +let materializeStartupError: Error; + +async function mirrorThenReject(metadata: ISessionMetadata): Promise { + await metadata.update({ title: 'transient' }); + throw materializeStartupError; +} + +class FailingSessionToolPolicy implements ISessionToolPolicy { + declare readonly _serviceBrand: undefined; + readonly ready: Promise; + readonly onDidChange = Event.None as ISessionToolPolicy['onDidChange']; + + constructor(@ISessionMetadata metadata: ISessionMetadata) { + this.ready = mirrorThenReject(metadata); + } + + disabledTools(): readonly string[] { + return []; + } + + setDisabledTools(): Promise { + return Promise.resolve(); + } +} + +class FailingSessionAgentProfileCatalog implements ISessionAgentProfileCatalog { + declare readonly _serviceBrand: undefined; + readonly ready: Promise; + readonly onDidChange = + Event.None as ISessionAgentProfileCatalog['onDidChange']; + + constructor(@ISessionMetadata metadata: ISessionMetadata) { + this.ready = mirrorThenReject(metadata); + } + + get(): undefined { + return undefined; + } + + getDefault(): never { + throw new Error('not implemented'); + } + + list(): readonly never[] { + return []; + } + + load(): Promise { + return Promise.resolve(); + } + + reload(): Promise { + return Promise.resolve(); + } +} + +class FailingSessionMcpService implements ISessionMcpService { + declare readonly _serviceBrand: undefined; + + constructor(@ISessionMetadata private readonly metadata: ISessionMetadata) {} + + ensureMcpReady(): Promise { + return mirrorThenReject(this.metadata); + } + + connectionManager(): never { + throw new Error('not implemented'); + } +} + let recordedSessionHookEvents: string[] = []; class RecordingSessionExternalHooksService @@ -425,6 +592,7 @@ describe('SessionLifecycleService', () => { let tmpRoots: string[]; beforeEach(() => { + disposedSessionScopes = []; recordedSessionHookEvents = []; telemetryRecords = []; tmpRoots = []; @@ -474,7 +642,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, @@ -493,6 +664,134 @@ describe('SessionLifecycleService', () => { return root; } + function registerRecordingSessionDisposal(): void { + registerScopedService( + LifecycleScope.Session, + ISessionExternalHooksService, + RecordingSessionDisposalService, + ScopeActivation.OnScopeCreated, + 'externalHooks', + ); + } + + function realAppendLogPair( + root: string, + storage: IFileSystemStorageService = new FileStorageService(root), + ): ReturnType { + return stubPair(IAppendLogStore, new AppendLogStore(storage)); + } + + function buildReadModel( + root: string, + extra: ReturnType[] = [], + failingBoundary?: 'tool policy' | 'agent profile' | 'MCP', + ): { + readonly svc: ISessionLifecycleService; + readonly index: ISessionIndex; + readonly queryStore: IQueryStore; + readonly bootstrap: IBootstrapService; + } { + const bootstrap = tmpBootstrapStub(root); + const fileStorage = new FileStorageService(root); + const materializationServices: ReturnType[] = []; + if (failingBoundary === 'tool policy') { + registerScopedService( + LifecycleScope.Session, + ISessionToolPolicy, + FailingSessionToolPolicy, + ScopeActivation.OnDemand, + 'failingSessionToolPolicy', + ); + } else { + materializationServices.push(stubPair(ISessionToolPolicy, sessionToolPolicyStub())); + } + if (failingBoundary === 'agent profile') { + registerScopedService( + LifecycleScope.Session, + ISessionAgentProfileCatalog, + FailingSessionAgentProfileCatalog, + ScopeActivation.OnDemand, + 'failingSessionAgentProfileCatalog', + ); + } else { + materializationServices.push( + stubPair(ISessionAgentProfileCatalog, agentProfileCatalogStub()), + ); + } + if (failingBoundary === 'MCP') { + registerScopedService( + LifecycleScope.Session, + ISessionMcpService, + FailingSessionMcpService, + ScopeActivation.OnDemand, + 'failingSessionMcp', + ); + } else { + materializationServices.push(stubPair(ISessionMcpService, sessionMcpServiceStub())); + } + registerScopedService( + LifecycleScope.App, + ISessionIndex, + FileSessionIndex, + ScopeActivation.OnDemand, + 'sessionIndex', + ); + registerScopedService( + LifecycleScope.App, + IQueryStore, + MiniDbQueryStore, + ScopeActivation.OnDemand, + 'storage', + ); + registerScopedService( + LifecycleScope.Session, + ISessionStateService, + SessionStateService, + ScopeActivation.OnScopeCreated, + 'state', + ); + registerScopedService( + LifecycleScope.Session, + ISessionMetadata, + SessionMetadata, + ScopeActivation.OnScopeCreated, + 'sessionMetadata', + ); + host = createScopedTestHost([ + stubPair(IBootstrapService, bootstrap), + stubPair(IFileSystemStorageService, fileStorage), + stubPair(IAtomicDocumentStore, new JsonAtomicDocumentStore(fileStorage)), + stubPair(IHostEnvironment, hostEnvironmentStub()), + stubPair(ISessionSkillCatalog, skillCatalogStub()), + stubPair(IWorkspaceService, persistentWorkspaceStub()), + stubPair(IAppendLogStore, appendLogStoreStub()), + stubPair(IEventService, eventStub()), + stubPair(IAgentLifecycleService, agentLifecycleStub()), + stubPair(IConfigService, configStub()), + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => Promise.resolve(), + } as unknown as ISessionCronService), + stubPair(ISessionSecondaryModelWarningService, { + _serviceBrand: undefined, + getSecondaryModelWarning: () => undefined, + } as ISessionSecondaryModelWarningService), + stubPair(IProjectLocalConfigService, projectLocalConfigStub()), + stubPair(ITelemetryService, recordingTelemetry(telemetryRecords)), + stubPair(ICronTaskPersistence, cronStoreStub()), + stubPair(IFlagService, stubFlag(true)), + stubPair(ILogService, stubLog()), + ...materializationServices, + ...extra, + ]); + return { + svc: host.app.accessor.get(ISessionLifecycleService), + index: host.app.accessor.get(ISessionIndex), + queryStore: host.app.accessor.get(IQueryStore), + bootstrap, + }; + } + it('create / get / list / close', async () => { const svc = build(); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); @@ -504,6 +803,287 @@ describe('SessionLifecycleService', () => { expect(svc.get('s1')).toBeUndefined(); }); + it('rollbackResume disposes the expected session after its close hook rejects', async () => { + registerRecordingSessionDisposal(); + 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 () => { + registerRecordingSessionDisposal(); + 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 () => { + registerRecordingSessionDisposal(); + 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('rollbackResume restores the live generation replaced by a completed resume', async () => { + let releaseIndex!: (summary: SessionSummary) => void; + const indexResult = new Promise((resolve) => { + releaseIndex = resolve; + }); + let markIndexRead!: () => void; + const indexRead = new Promise((resolve) => { + markIndexRead = resolve; + }); + const svc = build([ + stubPair(IWorkspaceService, persistentWorkspaceStub()), + stubPair(ISessionIndex, { + ...sessionIndexStub(), + get: () => { + markIndexRead(); + return indexResult; + }, + }), + stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), + ]); + + const resuming = svc.resume('s1'); + await indexRead; + const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + releaseIndex({ + id: 's1', + workspaceId: encodeWorkDirKey('/tmp/proj'), + cwd: '/tmp/proj', + createdAt: 1, + updatedAt: 1, + archived: false, + }); + const replacement = await resuming; + + expect(replacement).toBeDefined(); + expect(replacement).not.toBe(original); + + svc.rollbackResume(replacement!); + + expect(svc.get('s1')).toBe(original); + expect(svc.list()).toEqual([original]); + }); + + it('splices a rolled-back middle generation so rolling back its successor restores the original', async () => { + const svc = build(); + const original = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const middle = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const current = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + svc.rollbackResume(middle); + + expect(svc.get('s1')).toBe(current); + + svc.rollbackResume(current); + + expect(svc.get('s1')).toBe(original); + expect(svc.list()).toEqual([original]); + }); + + it('close drains every hidden generation without restoring an older handle', async () => { + registerRecordingSessionDisposal(); + const svc = build(); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const closed: string[] = []; + const subscription = svc.onDidCloseSession((event) => closed.push(event.sessionId)); + + try { + await svc.close('s1'); + + expect(svc.get('s1')).toBeUndefined(); + expect(svc.list()).toEqual([]); + expect(disposedSessionScopes).toEqual(['s1', 's1']); + expect(closed).toEqual(['s1']); + } finally { + subscription.dispose(); + } + }); + + it('close leaves a new generation created during hidden-generation draining current', async () => { + registerRecordingSessionDisposal(); + const agent = { + id: MAIN_AGENT_ID, + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected agent service access'); + }, + }, + dispose: () => {}, + } as IAgentScopeHandle; + let markDrainStarted!: () => void; + const drainStarted = new Promise((resolve) => { + markDrainStarted = resolve; + }); + let releaseDrain!: () => void; + const drainReleased = new Promise((resolve) => { + releaseDrain = resolve; + }); + let removeCalls = 0; + const svc = build([ + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + list: () => [agent], + remove: async () => { + if (removeCalls++ === 0) { + markDrainStarted(); + await drainReleased; + } + }, + }), + ]); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const closed: string[] = []; + const subscription = svc.onDidCloseSession((event) => closed.push(event.sessionId)); + + try { + const closing = svc.close('s1'); + await drainStarted; + const current = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + releaseDrain(); + await closing; + + expect(svc.get('s1')).toBe(current); + expect(svc.list()).toEqual([current]); + expect(disposedSessionScopes).toEqual(['s1', 's1']); + expect(closed).toEqual([]); + } finally { + subscription.dispose(); + } + }); + + it('archive skips stale persistence and events when its handle is replaced in the close hook', async () => { + registerRecordingSessionDisposal(); + const root = await makeTmpRoot(); + const { svc } = buildReadModel(root); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const stale = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const setArchived = vi.spyOn(stale.accessor.get(ISessionMetadata), 'setArchived'); + let current: typeof stale | undefined; + const archived: string[] = []; + const subscription = svc.onDidArchiveSession((event) => archived.push(event.sessionId)); + const hook = svc.hooks.onWillCloseSession.register( + 'replace-before-archive-commit', + async (event, next) => { + if (event.handle === stale) { + current = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + } + await next(); + }, + ); + + try { + await svc.archive('s1'); + + expect(current).toBeDefined(); + expect(svc.get('s1')).toBe(current); + expect(setArchived).not.toHaveBeenCalled(); + expect(disposedSessionScopes).toEqual(['s1', 's1']); + expect(archived).toEqual([]); + } finally { + hook.dispose(); + subscription.dispose(); + } + }); + + it('close leaves a replacement session installed by its hook live', async () => { + registerRecordingSessionDisposal(); + 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' }); @@ -560,7 +1140,7 @@ describe('SessionLifecycleService', () => { ...appendLogStoreStub(), append: (_scope: string, _key: string, record: unknown) => appended.push(record), }), - stubPair(IHostFileSystem, { remove } as unknown as IHostFileSystem), + stubPair(IHostFileSystem, pathAwareHostFileSystemStub(remove)), stubPair(IAgentLifecycleService, { ...agentLifecycleStub(), create, @@ -662,12 +1242,13 @@ describe('SessionLifecycleService', () => { }); it('does not cache a session whose tool policy fails to initialize', async () => { + const invalidToolPolicy = sessionToolPolicyStub(); + Object.defineProperty(invalidToolPolicy, 'ready', { + get: () => Promise.reject(new Error('invalid tool policy')), + }); const svc = build([ stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')), - stubPair(ISessionToolPolicy, { - ...sessionToolPolicyStub(), - ready: Promise.reject(new Error('invalid tool policy')), - }), + stubPair(ISessionToolPolicy, invalidToolPolicy), ]); await expect(svc.resume('s1')).rejects.toThrow('invalid tool policy'); @@ -839,20 +1420,971 @@ describe('SessionLifecycleService', () => { expect(captured).toMatchObject({ sessionId: 's1', handle: h, source: 'startup' }); }); - 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' }); - expect(telemetryRecords).toContainEqual({ - event: 'session_started', - properties: { sessionId: 's1', resumed: false }, + it('rolls back fresh persistence when a creation hook returns without reaching next', async () => { + const root = await makeTmpRoot(); + const { svc, index, queryStore, bootstrap } = buildReadModel(root, [ + realAppendLogPair(root), + ]); + const created: string[] = []; + const subscription = svc.onDidCreateSession((event) => created.push(event.sessionId)); + const hook = svc.hooks.onDidCreateSession.register('stop-before-terminal', async (event) => { + await event.handle.accessor.get(ISessionMetadata).update({ title: 'transient' }); }); - }); - it('keeps telemetry session context isolated when multiple sessions emit interleaved events', async () => { - const svc = build(); - const first = await svc.create({ sessionId: 'first', workDir: '/tmp/proj' }); - const second = await svc.create({ sessionId: 'second', workDir: '/tmp/proj' }); - telemetryRecords.length = 0; + try { + await expect( + svc.create({ sessionId: 's1', workDir: '/tmp/proj' }), + ).rejects.toThrow('Session creation hooks did not reach the lifecycle terminal'); + + expect(svc.get('s1')).toBeUndefined(); + expect(await queryStore.get('session', 's1')).toBeUndefined(); + expect(await index.get('s1')).toBeUndefined(); + await expect( + stat(bootstrap.sessionDir(encodeWorkDirKey('/tmp/proj'), 's1')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readLegacySessionIndex(root)).toEqual([]); + expect(created).toEqual([]); + } finally { + hook.dispose(); + subscription.dispose(); + } + }); + + it('awaits a fire-and-forget next and rolls back when its terminal rejects', async () => { + const root = await makeTmpRoot(); + const terminalError = new Error('cron terminal failed'); + let markCronStarted!: () => void; + const cronStarted = new Promise((resolve) => { + markCronStarted = resolve; + }); + let rejectCron!: (error: Error) => void; + const cronTerminal = new Promise((_resolve, reject) => { + rejectCron = reject; + }); + const { svc, index, queryStore, bootstrap } = buildReadModel(root, [ + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => { + markCronStarted(); + return cronTerminal; + }, + } as unknown as ISessionCronService), + ]); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + const created: string[] = []; + const subscription = svc.onDidCreateSession((event) => created.push(event.sessionId)); + const hook = svc.hooks.onDidCreateSession.register( + 'fire-and-forget-terminal', + (_event, next) => { + void next(); + }, + ); + + try { + let settled = false; + const outcomePromise = svc + .create({ sessionId: 's1', workDir: '/tmp/proj' }) + .then( + (value) => ({ status: 'fulfilled' as const, value }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ) + .finally(() => { + settled = true; + }); + + await cronStarted; + await Promise.resolve(); + expect(settled).toBe(false); + + rejectCron(terminalError); + const outcome = await outcomePromise; + expect(outcome).toEqual({ status: 'rejected', error: terminalError }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(svc.get('s1')).toBeUndefined(); + expect(await queryStore.get('session', 's1')).toBeUndefined(); + expect(await index.get('s1')).toBeUndefined(); + await expect( + stat(bootstrap.sessionDir(encodeWorkDirKey('/tmp/proj'), 's1')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(created).toEqual([]); + expect(unhandled).toEqual([]); + } finally { + hook.dispose(); + subscription.dispose(); + process.off('unhandledRejection', onUnhandled); + } + }); + + it('runs the terminal and legacy append once when a hook calls next repeatedly', async () => { + const root = await makeTmpRoot(); + const start = vi.fn(() => Promise.resolve()); + const { svc } = buildReadModel(root, [ + realAppendLogPair(root), + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start, + } as unknown as ISessionCronService), + ]); + const created: string[] = []; + const subscription = svc.onDidCreateSession((event) => created.push(event.sessionId)); + const hook = svc.hooks.onDidCreateSession.register( + 'repeat-terminal', + async (_event, next) => { + await Promise.all([next(), next(), next()]); + }, + ); + + try { + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + expect(start).toHaveBeenCalledOnce(); + expect(await readLegacySessionIndex(root)).toHaveLength(1); + expect(created).toEqual(['s1']); + } finally { + hook.dispose(); + subscription.dispose(); + } + }); + + it('retains committed persistence when the legacy append result is ambiguous', async () => { + const root = await makeTmpRoot(); + const appendError = new Error('append durability acknowledgement failed'); + const storage = new FileStorageService(root); + const ambiguousStorage = Object.create(storage) as IFileSystemStorageService; + ambiguousStorage.append = async (scope, key, data, options) => { + await storage.append(scope, key, data, options); + throw appendError; + }; + const { svc, index, queryStore, bootstrap } = buildReadModel(root, [ + realAppendLogPair(root, ambiguousStorage), + stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), + ]); + const created: string[] = []; + const subscription = svc.onDidCreateSession((event) => created.push(event.source)); + const hook = svc.hooks.onDidCreateSession.register( + 'mirror-before-append', + async (event, next) => { + if (event.source === 'startup') { + await event.handle.accessor.get(ISessionMetadata).update({ title: 'durable' }); + } + await next(); + }, + ); + + try { + await expect( + svc.create({ sessionId: 's1', workDir: '/tmp/proj' }), + ).rejects.toBe(appendError); + + expect(svc.get('s1')).toBeUndefined(); + expect(await queryStore.get('session', 's1')).toMatchObject({ title: 'durable' }); + expect(await index.get('s1')).toMatchObject({ title: 'durable' }); + expect( + (await stat(bootstrap.sessionDir(encodeWorkDirKey('/tmp/proj'), 's1'))).isDirectory(), + ).toBe(true); + expect(await readLegacySessionIndex(root)).toHaveLength(1); + expect(created).toEqual([]); + + const resumed = await svc.resume('s1'); + expect(resumed?.id).toBe('s1'); + expect(svc.get('s1')).toBe(resumed); + expect(created).toEqual(['resume']); + } finally { + hook.dispose(); + subscription.dispose(); + } + }); + + it('retains committed persistence when a creation hook throws after next', async () => { + const root = await makeTmpRoot(); + const hookError = new Error('post-terminal hook failed'); + const { svc, index, queryStore, bootstrap } = buildReadModel(root, [ + realAppendLogPair(root), + stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), + ]); + const created: string[] = []; + const subscription = svc.onDidCreateSession((event) => created.push(event.source)); + const hook = svc.hooks.onDidCreateSession.register( + 'throw-after-terminal', + async (event, next) => { + if (event.source === 'startup') { + await event.handle.accessor.get(ISessionMetadata).update({ title: 'durable' }); + await next(); + throw hookError; + } + await next(); + }, + ); + + try { + await expect( + svc.create({ sessionId: 's1', workDir: '/tmp/proj' }), + ).rejects.toBe(hookError); + + expect(svc.get('s1')).toBeUndefined(); + expect(await queryStore.get('session', 's1')).toMatchObject({ title: 'durable' }); + expect(await index.get('s1')).toMatchObject({ title: 'durable' }); + expect( + (await stat(bootstrap.sessionDir(encodeWorkDirKey('/tmp/proj'), 's1'))).isDirectory(), + ).toBe(true); + expect(await readLegacySessionIndex(root)).toHaveLength(1); + expect(created).toEqual([]); + + const resumed = await svc.resume('s1'); + expect(resumed?.id).toBe('s1'); + expect(svc.get('s1')).toBe(resumed); + expect(created).toEqual(['resume']); + } finally { + hook.dispose(); + subscription.dispose(); + } + }); + + it.each(['cron', 'pre-terminal hook'] as const)( + 'keeps the old workspace mapping and legacy line when a new workspace %s fails', + async (boundary) => { + const root = await makeTmpRoot(); + const failure = new Error(`${boundary} failed`); + let cronStarts = 0; + const { svc, index, bootstrap } = buildReadModel(root, [ + realAppendLogPair(root), + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => { + cronStarts += 1; + return boundary === 'cron' && cronStarts === 2 + ? Promise.reject(failure) + : Promise.resolve(); + }, + } as unknown as ISessionCronService), + ]); + const hook = svc.hooks.onDidCreateSession.register( + 'fail-new-workspace-before-terminal', + async (event, next) => { + if ( + boundary === 'pre-terminal hook' && + event.handle.accessor.get(ISessionContext).cwd === '/tmp/new-proj' + ) { + throw failure; + } + await next(); + }, + ); + + try { + const original = await svc.create({ sessionId: 's1', workDir: '/tmp/old-proj' }); + await original.accessor.get(ISessionMetadata).update({ title: 'original' }); + await svc.close('s1'); + + await expect( + svc.create({ sessionId: 's1', workDir: '/tmp/new-proj' }), + ).rejects.toBe(failure); + + expect(svc.get('s1')).toBeUndefined(); + expect(await index.get('s1')).toMatchObject({ + id: 's1', + workspaceId: encodeWorkDirKey('/tmp/old-proj'), + cwd: '/tmp/old-proj', + title: 'original', + }); + expect(await readLegacySessionIndex(root)).toEqual([ + { + sessionId: 's1', + sessionDir: bootstrap.sessionDir(encodeWorkDirKey('/tmp/old-proj'), 's1'), + workDir: '/tmp/old-proj', + }, + ]); + expect( + (await stat(bootstrap.sessionDir(encodeWorkDirKey('/tmp/old-proj'), 's1'))).isDirectory(), + ).toBe(true); + await expect( + stat(bootstrap.sessionDir(encodeWorkDirKey('/tmp/new-proj'), 's1')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + hook.dispose(); + } + }, + ); + + it('rejects a saved next after a failed resume without running the terminal later', async () => { + const root = await makeTmpRoot(); + 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 createAgent = vi.fn(() => { + liveMain = main; + return Promise.resolve(main); + }); + const start = vi.fn(() => Promise.resolve()); + const { svc } = buildReadModel(root, [ + realAppendLogPair(root), + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + get: (id) => (id === MAIN_AGENT_ID ? liveMain : undefined), + create: createAgent, + }), + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start, + } as unknown as ISessionCronService), + ]); + const created = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + await created.accessor.get(ISessionMetadata).update({ title: 'persisted' }); + await svc.close('s1'); + createAgent.mockClear(); + start.mockClear(); + let savedNext: (() => Promise) | undefined; + const hook = svc.hooks.onDidCreateSession.register('save-next', (_event, next) => { + savedNext = () => next(); + }); + + try { + await expect(svc.resume('s1')).rejects.toThrow( + 'Session creation hooks did not reach the lifecycle terminal', + ); + expect(svc.get('s1')).toBeUndefined(); + expect(savedNext).toBeDefined(); + + await expect(savedNext!()).rejects.toThrow( + 'Session creation hook terminal is already closed', + ); + + expect(createAgent).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + expect(svc.get('s1')).toBeUndefined(); + } finally { + hook.dispose(); + } + }); + + it('rejects creation as no longer current when a created listener disposes its handle', async () => { + const svc = build(); + const events: string[] = []; + const subscription = svc.onDidCreateSession((event) => { + events.push(`created:${event.sessionId}`); + event.handle.dispose(); + }); + + try { + await expect( + svc.create({ sessionId: 's1', workDir: '/tmp/proj' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + + expect(events).toEqual(['created:s1']); + expect(svc.get('s1')).toBeUndefined(); + expect(svc.list()).toEqual([]); + } finally { + subscription.dispose(); + } + }); + + it('rejects creation without returning an orphan when its created listener rolls it back', async () => { + const svc = build(); + const events: string[] = []; + const createdSubscription = svc.onDidCreateSession((event) => { + events.push(`created:${event.sessionId}`); + svc.rollbackResume(event.handle); + }); + const closedSubscription = svc.onDidCloseSession((event) => { + events.push(`closed:${event.sessionId}`); + }); + + try { + await expect( + svc.create({ sessionId: 's1', workDir: '/tmp/proj' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + + expect(svc.get('s1')).toBeUndefined(); + expect(svc.list()).toEqual([]); + expect(events).toEqual(['created:s1', 'closed:s1']); + } finally { + createdSubscription.dispose(); + closedSubscription.dispose(); + } + }); + + it('rejects fork without publishing create when its fork listener rolls the target back', async () => { + const svc = build([ + stubPair(IWorkspaceService, { + ...workspaceStub(), + get: () => + Promise.resolve({ + id: 'wd_stub', + root: '/tmp/proj', + name: 'stub', + createdAt: 0, + lastOpenedAt: 0, + }), + }), + ]); + const source = await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + const events: string[] = []; + const forkedSubscription = svc.onDidForkSession((event) => { + events.push(`forked:${event.sessionId}`); + svc.rollbackResume(event.handle); + }); + const createdSubscription = svc.onDidCreateSession((event) => { + events.push(`created:${event.sessionId}`); + }); + const closedSubscription = svc.onDidCloseSession((event) => { + events.push(`closed:${event.sessionId}`); + }); + + try { + await expect( + svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }), + ).rejects.toMatchObject({ code: ErrorCodes.SESSION_NOT_FOUND }); + + expect(svc.get('src')).toBe(source); + expect(svc.get('dst')).toBeUndefined(); + expect(events).toEqual(['forked:dst', 'closed:dst']); + } finally { + forkedSubscription.dispose(); + createdSubscription.dispose(); + closedSubscription.dispose(); + } + }); + + 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()); + registerRecordingSessionDisposal(); + const svc = build([ + stubPair(IHostFileSystem, pathAwareHostFileSystemStub(removeSessionDir)), + 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('removes mirrored read-model metadata when cron startup rolls back creation', async () => { + const root = await makeTmpRoot(); + const startupError = new Error('cron startup failed'); + const { svc, index, queryStore, bootstrap } = buildReadModel(root, [ + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => Promise.reject(startupError), + } as unknown as ISessionCronService), + ]); + svc.hooks.onDidCreateSession.register('mirror-before-cron', async (event, next) => { + await event.handle.accessor.get(ISessionMetadata).update({ title: 'transient' }); + await next(); + }); + + await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toBe(startupError); + + expect(await queryStore.get('session', 's1')).toBeUndefined(); + await expect( + stat(bootstrap.sessionDir(encodeWorkDirKey('/tmp/proj'), 's1')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await index.get('s1')).toBeUndefined(); + }); + + it.each(['tool policy', 'agent profile', 'MCP'] as const)( + 'removes fresh persistence after %s materialization fails without masking the failure', + async (boundary) => { + registerScopedService( + LifecycleScope.Session, + ISessionExternalHooksService, + ThrowingSessionDisposalService, + ScopeActivation.OnScopeCreated, + 'externalHooks', + ); + const root = await makeTmpRoot(); + materializeStartupError = new Error(`${boundary} materialization failed`); + const { svc, index, queryStore, bootstrap } = buildReadModel(root, [], boundary); + const workspaceId = encodeWorkDirKey('/tmp/proj'); + + await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toBe( + materializeStartupError, + ); + + expect(await queryStore.get('session', 's1')).toBeUndefined(); + await expect(stat(bootstrap.sessionDir(workspaceId, 's1'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect(await index.get('s1')).toBeUndefined(); + }, + ); + + it('keeps a nested replacement durable when the outer create hook fails', async () => { + const root = await makeTmpRoot(); + const startupError = new Error('first create hook failed'); + const { svc, index, queryStore, bootstrap } = buildReadModel(root); + let first = true; + let replacement: ReturnType; + svc.hooks.onDidCreateSession.register('replace-first-create', async (event, next) => { + if (first) { + first = false; + replacement = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + throw startupError; + } + await event.handle.accessor.get(ISessionMetadata).update({ title: 'replacement' }); + await next(); + }); + + await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toBe(startupError); + + expect(replacement).toBeDefined(); + expect(svc.get('s1')).toBe(replacement); + expect(await queryStore.get('session', 's1')).toMatchObject({ title: 'replacement' }); + expect(await index.get('s1')).toMatchObject({ title: 'replacement' }); + expect( + (await stat(bootstrap.sessionDir(encodeWorkDirKey('/tmp/proj'), 's1'))).isDirectory(), + ).toBe(true); + }); + + it.each(['agent drain', 'directory removal'] as const)( + 'serializes a same-path replacement across rollback %s', + async (blockedCleanup) => { + const root = await makeTmpRoot(); + const startupError = new Error('outer cron startup failed'); + let enterCleanup!: () => void; + const cleanupEntered = new Promise((resolve) => { + enterCleanup = resolve; + }); + let releaseCleanup!: () => void; + const cleanupReleased = new Promise((resolve) => { + releaseCleanup = resolve; + }); + let cleanupCalls = 0; + let liveMain: IAgentScopeHandle | undefined; + const makeMain = (): IAgentScopeHandle => + ({ + id: MAIN_AGENT_ID, + kind: LifecycleScope.Agent, + accessor: { + get: () => { + throw new Error('unexpected main agent service access'); + }, + }, + dispose: () => {}, + }) as IAgentScopeHandle; + const agents: IAgentLifecycleService = { + ...agentLifecycleStub(), + create: () => { + liveMain = makeMain(); + return Promise.resolve(liveMain); + }, + get: (agentId) => (agentId === MAIN_AGENT_ID ? liveMain : undefined), + list: () => (liveMain === undefined ? [] : [liveMain]), + remove: async (agentId) => { + if (blockedCleanup === 'agent drain' && cleanupCalls++ === 0) { + enterCleanup(); + await cleanupReleased; + } + if (liveMain?.id === agentId) liveMain = undefined; + }, + }; + const extra: ReturnType[] = [stubPair(IAgentLifecycleService, agents)]; + if (blockedCleanup === 'directory removal') { + const realHostFs = new HostFileSystem(); + const blockingHostFs = Object.create(realHostFs) as IHostFileSystem; + blockingHostFs.remove = async (path: string) => { + if (cleanupCalls++ === 0) { + enterCleanup(); + await cleanupReleased; + } + await realHostFs.remove(path); + }; + extra.push(stubPair(IHostFileSystem, blockingHostFs)); + } + let cronStarts = 0; + extra.push( + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => { + cronStarts += 1; + return cronStarts === 1 ? Promise.reject(startupError) : Promise.resolve(); + }, + } as unknown as ISessionCronService), + ); + const { svc, index, queryStore, bootstrap } = buildReadModel(root, extra); + let hookCalls = 0; + svc.hooks.onDidCreateSession.register('mirror-replacement', async (event, next) => { + hookCalls += 1; + await event.handle.accessor.get(ISessionMetadata).update({ + title: hookCalls === 1 ? 'outer' : 'replacement', + }); + await next(); + }); + const createOptions = { + sessionId: 's1', + workDir: '/tmp/proj', + mainAgentBinding: { profile: 'agent', model: 'mock' }, + } as const; + const failedCreate = svc.create(createOptions).then( + () => undefined, + (error: unknown) => error, + ); + await cleanupEntered; + + let replacementSettled = false; + const replacementPromise = svc.create(createOptions); + void replacementPromise.then( + () => { + replacementSettled = true; + }, + () => { + replacementSettled = true; + }, + ); + for (let turn = 0; turn < 64; turn += 1) { + await new Promise((resolveTurn) => setImmediate(resolveTurn)); + } + const settledBeforeCleanup = replacementSettled; + releaseCleanup(); + + expect(await failedCreate).toBe(startupError); + const replacement = await replacementPromise; + + expect(settledBeforeCleanup).toBe(false); + expect(svc.get('s1')).toBe(replacement); + expect(await queryStore.get('session', 's1')).toMatchObject({ title: 'replacement' }); + expect(await index.get('s1')).toMatchObject({ title: 'replacement' }); + expect( + JSON.parse( + await readFile( + join( + bootstrap.sessionDir(encodeWorkDirKey('/tmp/proj'), 's1'), + 'state.json', + ), + 'utf8', + ), + ), + ).toMatchObject({ title: 'replacement' }); + }, + ); + + it('removes only the failed fresh workspace when another workspace persists the same id', async () => { + const root = await makeTmpRoot(); + const startupError = new Error('workspace B cron startup failed'); + let cronStarts = 0; + const { svc, index, queryStore, bootstrap } = buildReadModel(root, [ + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => { + cronStarts += 1; + return cronStarts === 1 ? Promise.resolve() : Promise.reject(startupError); + }, + } as unknown as ISessionCronService), + ]); + svc.hooks.onDidCreateSession.register('mirror-workspace', async (event, next) => { + const workspaceId = event.handle.accessor.get(ISessionContext).workspaceId; + await event.handle.accessor.get(ISessionMetadata).update({ + title: + workspaceId === encodeWorkDirKey('/tmp/workspace-a') + ? 'workspace A' + : 'workspace B', + }); + await next(); + }); + + await svc.create({ sessionId: 's1', workDir: '/tmp/workspace-a' }); + await svc.close('s1'); + + await expect( + svc.create({ sessionId: 's1', workDir: '/tmp/workspace-b' }), + ).rejects.toBe(startupError); + + const workspaceA = encodeWorkDirKey('/tmp/workspace-a'); + const workspaceB = encodeWorkDirKey('/tmp/workspace-b'); + const metadataA = JSON.parse( + await readFile(join(bootstrap.sessionDir(workspaceA, 's1'), 'state.json'), 'utf8'), + ) as { title?: string }; + expect(metadataA.title).toBe('workspace A'); + await expect(stat(bootstrap.sessionDir(workspaceB, 's1'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect(await queryStore.get('session', 's1')).toBeUndefined(); + expect(await index.get('s1')).toMatchObject({ + workspaceId: workspaceA, + title: 'workspace A', + }); + }); + + it.each(['without metadata', 'with corrupt metadata'] as const)( + 'preserves a pre-existing session directory %s when creation fails', + async (existingState) => { + const root = await makeTmpRoot(); + const startupError = new Error('cron startup failed'); + const bootstrap = tmpBootstrapStub(root); + const sessionDir = bootstrap.sessionDir(encodeWorkDirKey('/tmp/proj'), 's1'); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'sentinel.txt'), 'keep me'); + if (existingState === 'with corrupt metadata') { + await writeFile(join(sessionDir, 'state.json'), '{not-json'); + } + const { svc } = buildReadModel(root, [ + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => Promise.reject(startupError), + } as unknown as ISessionCronService), + ]); + + await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toBeDefined(); + + expect(await readFile(join(sessionDir, 'sentinel.txt'), 'utf8')).toBe('keep me'); + }, + ); + + it('does not delete a persisted session when replacement startup fails', async () => { + const remove = vi.fn(() => Promise.resolve()); + const invalidate = vi.fn(() => Promise.resolve()); + const startupError = new Error('cron startup failed'); + const summary = { + id: 's1', + workspaceId: 'wd_stub', + cwd: '/tmp/proj', + createdAt: 1, + updatedAt: 2, + archived: false, + } satisfies SessionSummary; + const svc = build([ + stubPair(IHostFileSystem, pathAwareHostFileSystemStub(remove, true)), + stubPair(ISessionIndex, { + ...sessionIndexStub(), + get: () => Promise.resolve(summary), + invalidate, + }), + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => Promise.reject(startupError), + } as unknown as ISessionCronService), + ]); + + await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toBe(startupError); + + expect(remove).not.toHaveBeenCalled(); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it('restores the previous live handle when replacement startup fails', async () => { + const remove = vi.fn(() => Promise.resolve()); + const invalidate = vi.fn(() => Promise.resolve()); + const startupError = new Error('replacement cron startup failed'); + let cronStarts = 0; + const svc = build([ + stubPair(IHostFileSystem, pathAwareHostFileSystemStub(remove)), + stubPair(ISessionIndex, { + ...sessionIndexStub(), + invalidate, + }), + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => { + cronStarts += 1; + return cronStarts === 1 ? Promise.resolve() : Promise.reject(startupError); + }, + } as unknown as ISessionCronService), + ]); + const existing = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + + await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toBe(startupError); + + expect(svc.get('s1')).toBe(existing); + expect(remove).not.toHaveBeenCalled(); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it('preserves the startup error when rollback cleanup fails', async () => { + registerScopedService( + LifecycleScope.Session, + ISessionExternalHooksService, + ThrowingSessionDisposalService, + ScopeActivation.OnScopeCreated, + 'externalHooks', + ); + const startupError = new Error('cron startup failed'); + const invalidate = vi.fn(() => Promise.resolve()); + const agent = { + id: MAIN_AGENT_ID, + kind: LifecycleScope.Agent, + accessor: { get: () => ({}) }, + dispose: () => {}, + } as unknown as IAgentScopeHandle; + const svc = build([ + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + list: () => [agent], + remove: () => Promise.reject(new Error('agent removal failed')), + }), + stubPair( + IHostFileSystem, + pathAwareHostFileSystemStub(() => + Promise.reject(new Error('session directory removal failed')), + ), + ), + stubPair(ISessionIndex, { + ...sessionIndexStub(), + invalidate, + }), + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => Promise.reject(startupError), + } as unknown as ISessionCronService), + ]); + + await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toBe(startupError); + + expect(invalidate).not.toHaveBeenCalled(); + }); + + it('preserves the startup error when read-model invalidation fails', async () => { + const startupError = new Error('cron startup failed'); + const invalidate = vi.fn(() => Promise.reject(new Error('read-model invalidation failed'))); + const svc = build([ + stubPair(IHostFileSystem, pathAwareHostFileSystemStub(() => Promise.resolve())), + stubPair(ISessionIndex, { + ...sessionIndexStub(), + invalidate, + }), + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => Promise.reject(startupError), + } as unknown as ISessionCronService), + ]); + + await expect(svc.create({ sessionId: 's1', workDir: '/tmp/proj' })).rejects.toBe(startupError); + + expect(invalidate).toHaveBeenCalledWith('s1', 'wd_stub'); + }); + + 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' }); + expect(telemetryRecords).toContainEqual({ + event: 'session_started', + properties: { sessionId: 's1', resumed: false }, + }); + }); + + it('keeps telemetry session context isolated when multiple sessions emit interleaved events', async () => { + const svc = build(); + const first = await svc.create({ sessionId: 'first', workDir: '/tmp/proj' }); + const second = await svc.create({ sessionId: 'second', workDir: '/tmp/proj' }); + telemetryRecords.length = 0; first.accessor.get(ITelemetryService).track('test_event', { marker: 'first-before' }); second.accessor.get(ITelemetryService).track('test_event', { marker: 'second' }); @@ -920,6 +2452,80 @@ describe('SessionLifecycleService', () => { }); }); + it('keeps persisted state intact so a cron-failed resume can be retried', async () => { + const root = await makeTmpRoot(); + 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; + let cronStarts = 0; + const removeAgent = vi.fn((agentId: string) => { + if (liveMain?.id === agentId) liveMain = undefined; + return Promise.resolve(); + }); + registerRecordingSessionDisposal(); + const { svc, index, queryStore, bootstrap } = buildReadModel(root, [ + 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: () => { + cronStarts += 1; + return cronStarts === 2 ? Promise.reject(startupError) : Promise.resolve(); + }, + } as unknown as ISessionCronService), + ]); + const created = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + await created.accessor.get(ISessionMetadata).update({ title: 'persisted' }); + await svc.close('s1'); + disposedSessionScopes = []; + + 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(await queryStore.get('session', 's1')).toMatchObject({ title: 'persisted' }); + expect(await index.get('s1')).toMatchObject({ title: 'persisted' }); + expect( + JSON.parse( + await readFile( + join( + bootstrap.sessionDir(encodeWorkDirKey('/tmp/proj'), 's1'), + 'state.json', + ), + 'utf8', + ), + ), + ).toMatchObject({ title: 'persisted' }); + expect(telemetryRecords).toContainEqual({ + event: 'session_load_failed', + properties: { sessionId: 's1', reason: 'Error' }, + }); + + const retried = await svc.resume('s1'); + + expect(retried?.id).toBe('s1'); + expect(svc.get('s1')).toBe(retried); + }); + it('runs constructor-registered session lifecycle hooks before returning create and close', async () => { registerScopedService( LifecycleScope.Session, @@ -1008,6 +2614,49 @@ describe('SessionLifecycleService', () => { expect(archived).toEqual(['s1']); }); + it('publishes close and disposes the scope once when close is called concurrently', async () => { + registerRecordingSessionDisposal(); + const svc = build(); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const closed: string[] = []; + const subscription = svc.onDidCloseSession((event) => closed.push(event.sessionId)); + + try { + await Promise.all([svc.close('s1'), svc.close('s1')]); + + expect(svc.get('s1')).toBeUndefined(); + expect(disposedSessionScopes).toEqual(['s1']); + expect(closed).toEqual(['s1']); + } finally { + subscription.dispose(); + } + }); + + it('publishes archive and disposes the scope once when archive is called concurrently', async () => { + registerRecordingSessionDisposal(); + const publish = vi.fn(); + const svc = build([ + stubPair(IEventService, { + ...eventStub(), + publish, + }), + ]); + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + const archived: string[] = []; + const subscription = svc.onDidArchiveSession((event) => archived.push(event.sessionId)); + + try { + await Promise.all([svc.archive('s1'), svc.archive('s1')]); + + expect(svc.get('s1')).toBeUndefined(); + expect(disposedSessionScopes).toEqual(['s1']); + expect(archived).toEqual(['s1']); + expect(publish).toHaveBeenCalledOnce(); + } finally { + subscription.dispose(); + } + }); + describe('additional dirs', () => { beforeEach(() => { registerScopedService( @@ -1261,9 +2910,14 @@ describe('SessionLifecycleService', () => { it('rolls back the target session when fork fails after materializing', async () => { const root = await makeTmpRoot(); const srcDir = join(root, 'sessions', 'wd_stub', 'src'); + const invalidate = vi.fn(() => Promise.resolve()); const svc = build([ stubPair(IBootstrapService, tmpBootstrapStub(root)), workspaceGetStub(), + stubPair(ISessionIndex, { + ...sessionIndexStub(), + invalidate, + }), stubPair(ISessionMetadata, { ...metadataStub(), read: () => @@ -1283,11 +2937,39 @@ describe('SessionLifecycleService', () => { expect(svc.get('dst')).toBeUndefined(); await expect(stat(dstDir)).rejects.toThrow(); + expect(invalidate).toHaveBeenCalledWith('dst', 'wd_stub'); await expect(svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' })).rejects.toThrow( 'not implemented', ); }); + it('removes mirrored read-model metadata when fork cron startup rolls back', async () => { + const root = await makeTmpRoot(); + const startupError = new Error('fork cron startup failed'); + let cronStarts = 0; + const { svc, index, queryStore, bootstrap } = buildReadModel(root, [ + stubPair(ISessionCronService, { + _serviceBrand: undefined, + start: () => { + cronStarts += 1; + return cronStarts === 1 ? Promise.resolve() : Promise.reject(startupError); + }, + } as unknown as ISessionCronService), + ]); + await svc.create({ sessionId: 'src', workDir: '/tmp/proj' }); + + await expect( + svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' }), + ).rejects.toBe(startupError); + + expect(await queryStore.get('session', 'dst')).toBeUndefined(); + await expect( + stat(bootstrap.sessionDir(encodeWorkDirKey('/tmp/proj'), 'dst')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await index.get('dst')).toBeUndefined(); + expect(svc.get('src')?.id).toBe('src'); + }); + it('duplicates the source session cron tasks for the fork', async () => { const root = await makeTmpRoot(); const cron = cronStoreStub([ diff --git a/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts b/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts index a290740edb..5479d2464b 100644 --- a/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts @@ -35,6 +35,8 @@ class FakeSessionIndex implements ISessionIndex { return undefined; } + async invalidate(_id: string, _expectedWorkspaceId: string): Promise {} + async countActive(_workspaceIds: readonly string[]): Promise { return 0; } 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/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/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/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/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index ccdeed9399..752eb86ab8 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< @@ -169,6 +195,19 @@ export class TurnFlow { // so no caller — the SDK/RPC prompt path included — can poison the // session. Upstream ingestion points already gate; this is the backstop. const gated = gateImageFormatParts(input); + if (this.turnStartGateCount > 0) { + // A prompt admission needs its own matching turn.started event. Do not + // put it behind producer steers in the shared gate FIFO, where it could + // be folded into the producer's turn and leave the caller waiting. + this.agent.emitEvent({ + type: 'error', + ...makeErrorPayload( + ErrorCodes.TURN_AGENT_BUSY, + 'Cannot launch a new turn while session resume is in progress', + ), + }); + return null; + } this.agent.records.logRecord({ type: 'turn.prompt', input: gated, @@ -182,6 +221,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 +255,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 +285,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 +294,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 +329,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 +390,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 +503,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 `