From 179df8b1cf4ecd06c728f35192b2b754f1baa457 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Thu, 10 Sep 2026 17:46:37 +0800 Subject: [PATCH 1/8] feat(agent-core-v2): add full compaction to the human agent domain (#3703) --- .../agent-core-v2/src/human/agent/machine.ts | 52 +- .../agent-core-v2/src/human/agent/turn.ts | 58 +- .../compaction/compaction-instruction.md | 73 +++ .../compaction/compaction-summary-prefix.md | 1 + .../src/human/compaction/controller.ts | 247 ++++++++ .../src/human/compaction/errors.ts | 31 + .../src/human/compaction/machine.ts | 498 ++++++++++++++++ .../src/human/compaction/shape.ts | 205 +++++++ .../src/human/compaction/summarize.ts | 126 ++++ packages/agent-core-v2/src/human/index.ts | 4 + .../agent-core-v2/src/human/session/events.ts | 33 +- .../agent-core-v2/src/human/session/stores.ts | 34 +- .../src/human/test/agent/machine.test.ts | 140 +++++ .../human/test/compaction/controller.test.ts | 541 ++++++++++++++++++ .../src/human/test/session/stores.test.ts | 59 +- 15 files changed, 2092 insertions(+), 10 deletions(-) create mode 100644 packages/agent-core-v2/src/human/compaction/compaction-instruction.md create mode 100644 packages/agent-core-v2/src/human/compaction/compaction-summary-prefix.md create mode 100644 packages/agent-core-v2/src/human/compaction/controller.ts create mode 100644 packages/agent-core-v2/src/human/compaction/errors.ts create mode 100644 packages/agent-core-v2/src/human/compaction/machine.ts create mode 100644 packages/agent-core-v2/src/human/compaction/shape.ts create mode 100644 packages/agent-core-v2/src/human/compaction/summarize.ts create mode 100644 packages/agent-core-v2/src/human/test/compaction/controller.test.ts diff --git a/packages/agent-core-v2/src/human/agent/machine.ts b/packages/agent-core-v2/src/human/agent/machine.ts index 015e1681168..cf114adf1f8 100644 --- a/packages/agent-core-v2/src/human/agent/machine.ts +++ b/packages/agent-core-v2/src/human/agent/machine.ts @@ -43,6 +43,8 @@ export type AgentEvent = | { type: 'input.steer'; id: string } | { type: 'input.cancel'; id: string } | { type: 'input.abort' } + | { type: 'input.pause' } + | { type: 'input.continue' } | { type: 'turn.spawn_tools'; toolCalls: ToolCall[] } | { type: 'turn.drain' } | { type: 'turn.reminders_consumed'; reminders: HistoryMessage[] } @@ -89,6 +91,7 @@ export interface AgentMachineContext { activeTurnId?: number; branchId: string; drainedId?: string; + paused: boolean; } function completionNotification(toolCall: ToolCall, output: ToolOutput): UserEntry { @@ -169,6 +172,13 @@ function hasPendingWork(context: AgentMachineContext): boolean { return context.notifications.length > 0 || context.queue.length > 0; } +function historyEndsMidToolChain(messages: readonly HistoryMessage[]): boolean { + const last = messages.at(-1); + if (last === undefined) return false; + if (last.message.role === 'tool') return true; + return last.message.role === 'assistant' && last.message.toolCalls.length > 0; +} + function hasBackgroundWork(context: AgentMachineContext): boolean { return Object.keys(context.background).length > 0; } @@ -336,6 +346,7 @@ export function createAgentMachine({ queue: [], turnId: 0, branchId: 'main', + paused: false, }), invoke: [ { @@ -430,6 +441,12 @@ export function createAgentMachine({ target: '.idle', actions: ['abortScope', 'resetMirror', 'emitReset', 'forwardToParent'], }, + 'input.pause': { + actions: assign({ paused: true }), + }, + 'input.continue': { + actions: assign({ paused: false }), + }, 'store.error': { actions: 'forwardToParent', }, @@ -470,7 +487,7 @@ export function createAgentMachine({ idle: { initial: 'ready', always: { - guard: ({ context }) => hasPendingWork(context), + guard: ({ context }) => hasPendingWork(context) && !context.paused, target: 'running', actions: [ sendTo('store', ({ context }) => { @@ -492,6 +509,33 @@ export function createAgentMachine({ assign(({ context }) => drainPendingPatch(context)), ], }, + on: { + 'input.continue': { + guard: ({ context }) => + !hasPendingWork(context) && historyEndsMidToolChain(context.messages), + target: 'running', + actions: [ + assign({ paused: false }), + sendTo('store', ({ context }) => { + const head = context.queue[0]; + return { + type: 'store.append' as const, + event: [ + ...context.notifications.map((entry) => messageAppended({ message: entry })), + ...(head === undefined + ? [] + : [ + messageAppended({ message: createUserEntry(head.message, { source: 'input' }) }), + queueDrained({ id: head.id }), + ]), + ...(context.notifications.length === 0 ? [] : [notificationsDrained({})]), + ], + }; + }), + assign(({ context }) => drainPendingPatch(context)), + ], + }, + }, states: { ready: { always: { @@ -582,6 +626,12 @@ export function createAgentMachine({ 'forwardToParent', ], }, + 'input.pause': { + actions: [assign({ paused: true }), sendTo('turn', { type: 'turn.pause' as const })], + }, + 'input.continue': { + actions: [assign({ paused: false }), sendTo('turn', { type: 'turn.continue' as const })], + }, 'turn.drain': { actions: enqueueActions(({ context, enqueue }) => { const messages = [...context.notifications, ...context.reminders]; diff --git a/packages/agent-core-v2/src/human/agent/turn.ts b/packages/agent-core-v2/src/human/agent/turn.ts index 8566dd046d5..f42735399ed 100644 --- a/packages/agent-core-v2/src/human/agent/turn.ts +++ b/packages/agent-core-v2/src/human/agent/turn.ts @@ -1,4 +1,4 @@ -import { assign, raise, setup } from '#/xstate2'; +import { assign, fromPromise, raise, setup } from '#/xstate2'; import { emptyResponseError } from '#/llm/empty-response'; import type { LlmErrorMessage } from '#/llm/errors'; @@ -188,6 +188,8 @@ export type TurnEvent = | LlmEvent | TurnToolEvent | { type: 'turn.notify'; messages: HistoryMessage[] } + | { type: 'turn.pause' } + | { type: 'turn.continue' } | { type: 'turn.abort' } | { type: 'turn.failure.triaged'; @@ -224,6 +226,8 @@ export interface TurnMachineContext { delayMs: number; appliedRecoveries: LlmRecoveryRecord[]; recoveryMessages?: readonly Message[]; + paused: boolean; + lastError?: LlmRemoteErrorMessage; outcome?: 'done' | 'failed' | 'aborted'; error?: unknown; } @@ -342,11 +346,19 @@ function emptyErrorOf(context: TurnMachineContext): LlmErrorMessage<'empty_respo ); } +export interface TurnBeforeStepContext { + messages: readonly HistoryMessage[]; + request: LlmRequestConfig; +} + +export type TurnBeforeStep = (context: TurnBeforeStepContext) => void | Promise; + export interface CreateTurnMachineOptions { readonly recovery?: LlmRecovery; readonly retry?: LlmRetryOptions; readonly abortGraceMs?: number; readonly messageResolvers?: readonly MessageResolver[]; + readonly onBeforeStep?: TurnBeforeStep; } export function createTurnMachine( @@ -365,6 +377,9 @@ export function createTurnMachine( }, actors: { llmActor: createRequestActor(requester, options?.messageResolvers), + onBeforeStepActor: fromPromise(async ({ input }) => { + await options?.onBeforeStep?.(input); + }), }, actions: { forwardToParent: ({ self, event }) => { @@ -403,7 +418,7 @@ export function createTurnMachine( }, }).createMachine({ id: 'turn', - initial: 'thinking', + initial: 'gating', context: ({ input }) => { const toolCallIds = new ToolCallIdNormalizer(); toolCallIds.seedFrom(toInputMessages(input.history)); @@ -420,9 +435,36 @@ export function createTurnMachine( attempt: 1, delayMs: 0, appliedRecoveries: [], + paused: false, }; }, + on: { + 'turn.pause': { + actions: assign({ paused: true }), + }, + 'turn.continue': { + actions: assign({ paused: false }), + }, + }, states: { + gating: { + always: [{ guard: () => options?.onBeforeStep === undefined, target: 'thinking' }], + invoke: { + src: 'onBeforeStepActor', + input: ({ context }) => ({ + messages: [...context.input.history, ...context.produced], + request: context.input.request, + }), + onDone: { target: 'thinking' }, + onError: { target: 'done' }, + }, + on: { + 'turn.abort': { + target: 'aborted', + actions: assign({ outcome: 'aborted' as const }), + }, + }, + }, thinking: { entry: [ assign({ @@ -784,6 +826,16 @@ export function createTurnMachine( }, on: { 'turn.notify': [ + { + guard: ({ context }) => context.paused, + target: 'done', + actions: [ + assign(({ context, event }) => ({ + produced: [...context.produced, ...event.messages], + })), + 'signalRemindersConsumed', + ], + }, { guard: ({ context, event }) => event.messages.length === 0 && maxStepsExceeded(context), @@ -794,7 +846,7 @@ export function createTurnMachine( })), }, { - target: 'thinking', + target: 'gating', actions: [ assign(({ context, event }) => ({ produced: [...context.produced, ...event.messages], diff --git a/packages/agent-core-v2/src/human/compaction/compaction-instruction.md b/packages/agent-core-v2/src/human/compaction/compaction-instruction.md new file mode 100644 index 00000000000..90742b820bc --- /dev/null +++ b/packages/agent-core-v2/src/human/compaction/compaction-instruction.md @@ -0,0 +1,73 @@ +You are about to run out of context. Create a handoff summary for the +model that will resume this task after the earlier conversation is cleared. + +--- This message is a direct task, not part of the above conversation --- + +Do not impose rigid section headings; let the shape follow the task. Write it +in the same language the conversation has been using — do not switch to English +just because these instructions happen to be in English. + +Make the summary self-sufficient: the next turn will see only the preserved +messages and this summary — every other assistant message, tool call, and tool +result above will be gone. In your own words, preserve what you genuinely need +to continue: + +- What the latest request is actually asking for: your reading of its intent and + any ambiguity you have already resolved — not a re-transcription, since what + fits is kept verbatim in the preserved messages. But those kept messages are + size-capped, so a long request is truncated there: if the latest request is + large (a big paste or file), preserve the parts at risk of being dropped — + above all the actual ask. If several requests are in play, say which one governs + the next move, and re-quote any still-relevant earlier request that may have + scrolled out of the kept messages. +- The instructions and constraints currently in force (user preferences, + project rules, environment and tooling limits) — condensed to what still + matters, keeping decisions you have already settled (what you chose and why) + separate from questions still open, so you neither silently reopen a closed + choice nor treat an undecided point as decided. +- What has actually been done, at high fidelity: keep the exact commands that + were run, the exact file paths touched, and whether each succeeded or failed — + and the results themselves, not just the commands: the concrete values + returned, the key lines or error text, the schema or signature a lookup + revealed, since re-running to recover them may be slow or impossible. Keep only + the final working version of any code; drop intermediate attempts and + already-resolved errors. +- What you still don't know: context the next step depends on that this + conversation never established — files or paths referenced but not yet read, + schemas or APIs assumed but unseen, questions the user has not answered. Name + these gaps so the next turn goes and checks them instead of assuming. +- The forward plan — and this is the moment to invest in it. Right now you + hold more context on this task than you ever will again; the next turn + resumes with less, so the plan you commit here is the one it will follow. + Give the exact next command or tool call, but don't stop at the next step: + set out the remaining sequence to finish, the decisions you have already + made for those upcoming steps (so the next turn doesn't reopen them), the + obstacles or edge cases you can already foresee and how you mean to handle + them, and any work you can commit to now — the exact patch, query, or shape + of the final answer you already know you will produce. Anything you settle + here is one less thing the next turn must rediscover. Include any required + format for the final answer. + +This conversation's event log stays on disk and a recovery pointer is appended below this summary automatically, so you need not reproduce long outputs verbatim — keep exact identifiers, key values and error lines, and name anything the next turn should look up. + +Your TODO list is re-attached automatically below this summary from its live +source, so do not transcribe it — copying it wastes space and can contradict the +live version. What that list cannot hold is the reasoning between tasks — why one +was reordered or dropped, or a decision on one that constrains another — so +record that instead. + +Be honest about uncertainty. If an earlier step claimed something was done but +was never verified (tests "passing", a fix "working", a file "created"), say so +plainly and treat it as unverified rather than fact — re-check before relying +on it. + +Be concise, and keep the summary proportional to the task: a long multi-step +task warrants detail, but a trivial or nearly finished exchange needs only a +sentence or two — do not pad it out. Include the critical data, identifiers, and +references needed to continue, and omit anything that does not change the next +move. + +Respond with text only. Do not call any tools — you already have everything you +need in the conversation history. + +${custom_instruction_block} diff --git a/packages/agent-core-v2/src/human/compaction/compaction-summary-prefix.md b/packages/agent-core-v2/src/human/compaction/compaction-summary-prefix.md new file mode 100644 index 00000000000..3b8345bf345 --- /dev/null +++ b/packages/agent-core-v2/src/human/compaction/compaction-summary-prefix.md @@ -0,0 +1 @@ +The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary. The summary records which earlier requests were already addressed. diff --git a/packages/agent-core-v2/src/human/compaction/controller.ts b/packages/agent-core-v2/src/human/compaction/controller.ts new file mode 100644 index 00000000000..b3c58d97f42 --- /dev/null +++ b/packages/agent-core-v2/src/human/compaction/controller.ts @@ -0,0 +1,247 @@ +import { estimateUsedContextTokens } from '#/agent/context-usage'; +import type { ExternalEvent } from '#/eventStore/events'; +import type { UserMessage } from '#/llm/message'; +import { + compactionCancelled, + compactionCompleted, + compactionStarted, +} from '#/session/events'; +import type { AgentActorRef } from '#/session/machine'; +import type { SessionStores } from '#/session/stores'; +import type { TurnBeforeStep, TurnBeforeStepContext } from '#/agent/turn'; +import { createActor, waitFor, type ActorRefFrom, type Subscription } from '#/xstate2'; + +import { CompactError, isContextOverflowError } from './errors'; +import { + createCompactionMachine, + type CompactionEvent, + type CompactionMachineOutput, + type CompactionPhase, + type CompactionReason, +} from './machine'; +import type { Summarize } from './summarize'; + +export { + type CompactionCancelCause, + type CompactionEvent, + type CompactionPhase, + type CompactionReason, + type CompactionStats, +} from './machine'; + +export interface CompactionStatus { + phase: CompactionPhase; + reason?: CompactionReason; + startedAt?: number; +} + +export interface CompactionControllerDeps { + agentId: string; + actor: AgentActorRef; + stores: SessionStores; + summarize: Summarize; + budget: { + maxContextTokens(): number; + triggerRatio: number; + }; + continuation?: (reason: CompactionReason) => UserMessage | undefined; + maxAutoAttempts?: number; + todos?: () => string | undefined; + onEvent?: (event: CompactionEvent) => void; + onWillCompact?: (input: { + reason: CompactionReason; + instruction?: string; + signal: AbortSignal; + tokenCount: number; + }) => void | Promise; +} + +export interface CompactionController { + compact(instruction?: string): Promise<{ branchId: string }>; + cancel(): void; + status(): CompactionStatus; + onBeforeStep: TurnBeforeStep; + dispose(): void; +} + +type RunActor = ActorRefFrom>; + +interface ActiveRun { + actor: RunActor; + reason: CompactionReason; + startedAt: number; +} + +const DEFAULT_MAX_AUTO_ATTEMPTS = 3; + +function errorMessageOf(error: unknown): string | undefined { + if (error === undefined) return undefined; + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + return JSON.stringify(error) ?? String(typeof error); +} + +export function createCompactionController(deps: CompactionControllerDeps): CompactionController { + const maxAutoAttempts = deps.maxAutoAttempts ?? DEFAULT_MAX_AUTO_ATTEMPTS; + const machine = createCompactionMachine(deps); + let active: ActiveRun | undefined; + let pendingAuto: { reason: 'budget' | 'overflow' } | undefined; + let overflowAttempts = 0; + let lastCompactedTokens: number | undefined; + + const record = (event: ExternalEvent): void => { + void deps.stores + .session() + .then((session) => session.dispatch(event)) + .then( + () => undefined, + () => undefined, + ); + }; + + const budgetExceeded = (used: number): boolean => { + const max = deps.budget.maxContextTokens(); + if (max <= 0 || used < max * deps.budget.triggerRatio) return false; + return lastCompactedTokens === undefined || used > lastCompactedTokens; + }; + + const firePending = (): void => { + const scheduled = pendingAuto; + pendingAuto = undefined; + if (scheduled === undefined) return; + if (scheduled.reason === 'budget') { + const history = deps.stores.get(deps.agentId)?.getState().history; + if (history === undefined || !budgetExceeded(estimateUsedContextTokens(history))) { + return; + } + } + queueMicrotask(() => void run(scheduled.reason)); + }; + + const pipeEvents = (actor: RunActor): Subscription[] => [ + actor.on('compaction.started', (event) => { + deps.onEvent?.(event); + record( + compactionStarted({ + agentId: deps.agentId, + reason: event.reason, + instruction: event.instruction, + }), + ); + }), + actor.on('compaction.blocked', (event) => { + deps.onEvent?.(event); + }), + actor.on('compaction.completed', (event) => { + deps.onEvent?.(event); + record(compactionCompleted({ agentId: deps.agentId, branch: event.branchId })); + lastCompactedTokens = event.stats.tokensBefore; + active = undefined; + firePending(); + }), + actor.on('compaction.cancelled', (event) => { + deps.onEvent?.(event); + record( + compactionCancelled({ + agentId: deps.agentId, + cause: event.cause, + errorMessage: errorMessageOf(event.error), + }), + ); + active = undefined; + firePending(); + }), + ]; + + const run = async ( + reason: CompactionReason, + instruction?: string, + ): Promise<{ branchId: string } | undefined> => { + if (active !== undefined) { + if (reason === 'manual') { + throw new CompactError('busy', 'compaction is already running'); + } + pendingAuto = { reason }; + return undefined; + } + if (deps.stores.get(deps.agentId) === undefined) { + if (reason === 'manual') { + throw new CompactError('unknown-agent', `unknown agent: '${deps.agentId}'`); + } + return undefined; + } + const actor = createActor(machine, { input: { reason, instruction } }); + const current = { actor, reason, startedAt: Date.now() }; + active = current; + const subscriptions = pipeEvents(actor); + await deps.stores.session(); + actor.start(); + try { + const snapshot = await waitFor(actor, (s) => s.status !== 'active'); + const output = snapshot.output as CompactionMachineOutput; + if (output.status === 'completed') { + return { branchId: output.branchId }; + } + if (reason === 'manual') { + throw output.error; + } + return undefined; + } finally { + for (const subscription of subscriptions) { + subscription.unsubscribe(); + } + if (active === current) { + active = undefined; + } + actor.stop(); + } + }; + + const subscriptions: Subscription[] = [ + deps.actor.on('turn.done', () => { + overflowAttempts = 0; + }), + deps.actor.on('turn.failed', (event) => { + if (!isContextOverflowError(event.error) || overflowAttempts >= maxAutoAttempts) { + return; + } + overflowAttempts += 1; + queueMicrotask(() => void run('overflow')); + }), + deps.actor.on('turn.aborting', () => { + active?.actor.send({ type: 'cancel', cause: 'user-abort' }); + }), + ]; + + const onBeforeStep: TurnBeforeStep = async ({ messages, request }: TurnBeforeStepContext) => { + const used = estimateUsedContextTokens(messages, { + systemPrompt: request.systemPrompt, + tools: request.tools, + }); + if (!budgetExceeded(used)) return; + queueMicrotask(() => void run('budget')); + throw new CompactError('budget-blocked', 'context budget exceeded; compacting before next step'); + }; + + return { + compact: (instruction) => run('manual', instruction) as Promise<{ branchId: string }>, + cancel: () => { + active?.actor.send({ type: 'cancel', cause: 'cancelled' }); + }, + status: () => + active === undefined + ? { phase: 'idle' } + : { + phase: active.actor.getSnapshot().value as CompactionPhase, + reason: active.reason, + startedAt: active.startedAt, + }, + onBeforeStep, + dispose: () => { + active?.actor.send({ type: 'cancel', cause: 'cancelled' }); + for (const subscription of subscriptions) { + subscription.unsubscribe(); + } + }, + }; +} diff --git a/packages/agent-core-v2/src/human/compaction/errors.ts b/packages/agent-core-v2/src/human/compaction/errors.ts new file mode 100644 index 00000000000..8a918914778 --- /dev/null +++ b/packages/agent-core-v2/src/human/compaction/errors.ts @@ -0,0 +1,31 @@ +export type CompactErrorCode = + | 'busy' + | 'unknown-agent' + | 'insufficient' + | 'drift' + | 'summary-failed' + | 'aborted' + | 'cancelled' + | 'reset-timeout' + | 'budget-blocked'; + +export class CompactError extends Error { + readonly code: CompactErrorCode; + + constructor(code: CompactErrorCode, message: string) { + super(message); + this.name = 'CompactError'; + this.code = code; + } +} + +export function isContextOverflowError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + return (error as { kind?: unknown }).kind === 'context_overflow'; +} + +export function isShrinkableSummaryError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const kind = (error as { kind?: unknown }).kind; + return kind === 'context_overflow' || kind === 'empty_response'; +} diff --git a/packages/agent-core-v2/src/human/compaction/machine.ts b/packages/agent-core-v2/src/human/compaction/machine.ts new file mode 100644 index 00000000000..8a9f7595a2d --- /dev/null +++ b/packages/agent-core-v2/src/human/compaction/machine.ts @@ -0,0 +1,498 @@ +import { estimateUsedContextTokens } from '#/agent/context-usage'; +import { + inputCancelled, + inputNotified, + inputReminded, + inputSteered, + inputSubmitted, +} from '#/agent/events'; +import type { QueuedPrompt } from '#/agent/slices'; +import type { HistoryMessage } from '#/agent/turn'; +import type { ExternalEvent } from '#/eventStore/events'; +import type { SystemMessage, UserMessage } from '#/llm/message'; +import type { AgentActorRef } from '#/session/machine'; +import type { SessionStores } from '#/session/stores'; +import { assign, emit, enqueueActions, fromPromise, setup, waitFor } from '#/xstate2'; + +import { CompactError } from './errors'; +import { buildCompactionSeed, compactionContinuationMessage } from './shape'; +import type { Summarize, SummaryOutcome } from './summarize'; + +export type CompactionReason = 'budget' | 'manual' | 'overflow'; + +export type CompactionPhase = + | 'idle' + | 'quiescing' + | 'summarizing' + | 'switching' + | 'resuming' + | 'completed' + | 'cancelled'; + +export type CompactionCancelCause = 'cancelled' | 'user-abort' | 'drift' | 'failed'; + +export type SummaryTelemetry = Omit; + +export interface CompactionStats { + compactedCount: number; + tokensBefore: number; + tokensAfter: number; +} + +export type CompactionEvent = + | { type: 'compaction.started'; reason: CompactionReason; instruction?: string } + | { type: 'compaction.blocked'; turnId?: number } + | { + type: 'compaction.completed'; + reason: CompactionReason; + branchId: string; + stats: CompactionStats; + durationMs: number; + originTurnId?: number; + summary?: SummaryTelemetry; + } + | { + type: 'compaction.cancelled'; + reason: CompactionReason; + cause: CompactionCancelCause; + error?: unknown; + durationMs: number; + originTurnId?: number; + tokensBefore?: number; + }; + +export interface CompactionMachineDeps { + agentId: string; + actor: AgentActorRef; + stores: SessionStores; + summarize: Summarize; + continuation?: (reason: CompactionReason) => UserMessage | undefined; + todos?: () => string | undefined; + onWillCompact?: (input: { + reason: CompactionReason; + instruction?: string; + signal: AbortSignal; + tokenCount: number; + }) => void | Promise; +} + +export interface CompactionMachineInput { + reason: CompactionReason; + instruction?: string; +} + +export type CompactionMachineOutput = + | { status: 'completed'; branchId: string; stats: CompactionStats } + | { status: 'cancelled'; cause: CompactionCancelCause; error: unknown }; + +type CompactionMachineEvent = { type: 'cancel'; cause: 'cancelled' | 'user-abort' }; + +interface QuiesceSnapshot { + history: HistoryMessage[]; + queue: QueuedPrompt[]; + nextTurnId: number; + branch: string; + head: number | null; + tokensBefore: number; +} + +interface SummaryResult { + seedEvents: ExternalEvent[]; + stats: CompactionStats; + telemetry: SummaryTelemetry; +} + +interface CompactionMachineContext { + input: CompactionMachineInput; + startedAt: number; + cause?: CompactionCancelCause; + error?: unknown; + originTurnId?: number; + snap?: QuiesceSnapshot; + seedEvents?: ExternalEvent[]; + stats?: CompactionStats; + summaryTelemetry?: SummaryTelemetry; + branchId?: string; +} + +const PAUSE_TIMEOUT_MS = 300_000; +const RESET_TIMEOUT_MS = 20_000; + +const INPUT_DELTA_TYPES: ReadonlySet = new Set([ + inputSubmitted.type, + inputNotified.type, + inputReminded.type, + inputCancelled.type, + inputSteered.type, +]); + +function aborted(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + if (signal.aborted) { + reject(signal.reason as unknown); + return; + } + signal.addEventListener('abort', () => reject(signal.reason as unknown), { once: true }); + }); +} + +async function forEachDeltaEntry( + stores: SessionStores, + snapBranch: string, + snapHead: number | null, + visit: (type: string, data: Record) => void, +): Promise { + const branch = stores.tree.openBranch(snapBranch); + const head = branch.head; + if (head === null) return; + for (let seq = (snapHead ?? -1) + 1; seq <= head; seq++) { + const entry = branch.entryAt(seq); + if (entry === null) continue; + const data = (await stores.tree.resolve(entry)) as Record | null; + if (data === null) continue; + visit(entry.type, data); + } +} + +async function assertInputOnlyDelta( + stores: SessionStores, + snapBranch: string, + snapHead: number | null, +): Promise { + await forEachDeltaEntry(stores, snapBranch, snapHead, (type) => { + if (!INPUT_DELTA_TYPES.has(type)) { + throw new CompactError('drift', 'history changed during compaction; cancelled'); + } + }); +} + +async function replayInputDelta( + deps: CompactionMachineDeps, + snapBranch: string, + snapHead: number | null, +): Promise { + await forEachDeltaEntry(deps.stores, snapBranch, snapHead, (type, data) => { + if (type === inputSubmitted.type) { + deps.actor.send({ + type: 'input.submit', + id: data['id'] as string | undefined, + message: data['message'] as UserMessage, + }); + } else if (type === inputNotified.type) { + deps.actor.send({ type: 'input.notify', message: data['message'] as UserMessage }); + } else if (type === inputReminded.type) { + deps.actor.send({ + type: 'input.remind', + key: data['key'] as string, + message: data['message'] as UserMessage | SystemMessage, + }); + } else if (type === inputSteered.type) { + deps.actor.send({ type: 'input.steer', id: data['id'] as string }); + } else if (type === inputCancelled.type) { + deps.actor.send({ type: 'input.cancel', id: data['id'] as string }); + } + }); +} + +function waitForResetApplied(deps: CompactionMachineDeps, branchId: string): Promise { + if (deps.actor.getSnapshot().context.branchId === branchId) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + subscription.unsubscribe(); + reject(new CompactError('reset-timeout', `machine did not apply branch '${branchId}' in time`)); + }, RESET_TIMEOUT_MS); + const subscription = deps.actor.on('context.reset', (event) => { + if (event.branchId !== branchId) return; + clearTimeout(timer); + subscription.unsubscribe(); + resolve(); + }); + }); +} + +export function createCompactionMachine(deps: CompactionMachineDeps) { + return setup({ + types: { + input: {} as CompactionMachineInput, + context: {} as CompactionMachineContext, + events: {} as CompactionMachineEvent, + emitted: {} as CompactionEvent, + output: {} as CompactionMachineOutput, + }, + actors: { + quiesce: fromPromise(async ({ signal }) => { + const store = deps.stores.get(deps.agentId); + if (store === undefined) { + throw new CompactError('unknown-agent', `unknown agent: '${deps.agentId}'`); + } + deps.actor.send({ type: 'input.pause' }); + const waiting = waitFor(deps.actor, (s) => s.matches('idle'), { timeout: PAUSE_TIMEOUT_MS }); + void waiting.catch(() => undefined); + await Promise.race([waiting, aborted(signal)]); + await store.flush(); + const state = store.getState(); + if (state.history.length === 0) { + throw new CompactError('insufficient', 'nothing to compact'); + } + return { + history: state.history, + queue: state.queue, + nextTurnId: state.turnIndex.nextTurnId, + branch: store.ref.branch, + head: deps.stores.tree.openBranch(store.ref.branch).head, + tokensBefore: estimateUsedContextTokens(state.history), + }; + }), + summarize: fromPromise< + SummaryResult, + { snap: QuiesceSnapshot; reason: CompactionReason; instruction?: string } + >(async ({ input, signal }) => { + await deps.onWillCompact?.({ + reason: input.reason, + instruction: input.instruction, + signal, + tokenCount: input.snap.tokensBefore, + }); + const outcome = await deps.summarize({ + history: input.snap.history, + instruction: input.instruction, + signal, + }); + let summary = outcome.text; + const todoText = deps.todos?.(); + if (todoText !== undefined && todoText.length > 0) { + summary = `${summary.trim()}\n\n${todoText}`; + } + if (signal.aborted) throw signal.reason; + const store = deps.stores.get(deps.agentId); + if (store === undefined || store.ref.branch !== input.snap.branch) { + throw new CompactError('drift', 'branch switched during compaction; cancelled'); + } + await assertInputOnlyDelta(deps.stores, input.snap.branch, input.snap.head); + const seed = buildCompactionSeed({ + turnId: input.snap.nextTurnId, + history: input.snap.history, + summary, + queue: input.snap.queue, + }); + return { + seedEvents: seed.events, + stats: { + compactedCount: input.snap.history.length, + tokensBefore: input.snap.tokensBefore, + tokensAfter: seed.tokensAfter, + }, + telemetry: { + usage: outcome.usage, + traceId: outcome.traceId, + attempts: outcome.attempts, + droppedCount: outcome.droppedCount, + }, + }; + }), + switchStore: fromPromise<{ branchId: string }, { seedEvents: ExternalEvent[]; stats: CompactionStats }>( + async ({ input }) => { + const { branchId } = await deps.stores.switchBranch(deps.agentId, { + reason: 'compaction', + stats: { + compactedCount: input.stats.compactedCount, + tokensBefore: input.stats.tokensBefore, + tokensAfter: input.stats.tokensAfter, + }, + seed: input.seedEvents, + }); + await waitForResetApplied(deps, branchId); + return { branchId }; + }, + ), + resume: fromPromise( + async ({ input }) => { + await replayInputDelta(deps, input.branch, input.head); + const continuation = (deps.continuation ?? defaultContinuation)(input.reason); + if (continuation !== undefined) { + deps.actor.send({ type: 'input.submit', message: continuation }); + } + deps.actor.send({ type: 'input.continue' }); + }, + ), + }, + }).createMachine({ + id: 'compaction', + initial: 'quiescing', + context: ({ input }) => ({ input, startedAt: Date.now() }), + on: { + cancel: {}, + }, + states: { + quiescing: { + entry: [ + emit(({ context }) => ({ + type: 'compaction.started' as const, + reason: context.input.reason, + instruction: context.input.instruction, + })), + assign({ + originTurnId: ({ context }) => + context.input.reason === 'manual' + ? undefined + : deps.actor.getSnapshot().context.activeTurnId, + }), + enqueueActions(({ enqueue }) => { + const snapshot = deps.actor.getSnapshot(); + if (!snapshot.matches('idle')) { + enqueue.emit({ + type: 'compaction.blocked', + turnId: snapshot.context.activeTurnId, + }); + } + }), + ], + invoke: { + src: 'quiesce', + onDone: { + target: 'summarizing', + actions: assign({ snap: ({ event }) => event.output }), + }, + onError: { + target: 'cancelled', + actions: assign(({ event }) => ({ + cause: (event.error instanceof CompactError && event.error.code === 'drift' + ? 'drift' + : 'failed') as CompactionCancelCause, + error: event.error, + })), + }, + }, + on: { + cancel: { + target: 'cancelled', + actions: assign(({ event }) => ({ + cause: event.cause as CompactionCancelCause, + error: cancelError(event.cause), + })), + }, + }, + }, + summarizing: { + invoke: { + src: 'summarize', + input: ({ context }) => ({ + snap: context.snap as QuiesceSnapshot, + reason: context.input.reason, + instruction: context.input.instruction, + }), + onDone: { + target: 'switching', + actions: assign({ + seedEvents: ({ event }) => event.output.seedEvents, + stats: ({ event }) => event.output.stats, + summaryTelemetry: ({ event }) => event.output.telemetry, + }), + }, + onError: { + target: 'cancelled', + actions: assign(({ event }) => ({ + cause: (event.error instanceof CompactError && event.error.code === 'drift' + ? 'drift' + : 'failed') as CompactionCancelCause, + error: event.error, + })), + }, + }, + on: { + cancel: { + target: 'cancelled', + actions: assign(({ event }) => ({ + cause: event.cause as CompactionCancelCause, + error: cancelError(event.cause), + })), + }, + }, + }, + switching: { + invoke: { + src: 'switchStore', + input: ({ context }) => ({ + seedEvents: context.seedEvents as ExternalEvent[], + stats: context.stats as CompactionStats, + }), + onDone: { + target: 'resuming', + actions: assign({ branchId: ({ event }) => event.output.branchId }), + }, + onError: { + target: 'cancelled', + actions: assign({ cause: 'failed' as CompactionCancelCause, error: ({ event }) => event.error }), + }, + }, + }, + resuming: { + invoke: { + src: 'resume', + input: ({ context }) => ({ + branch: (context.snap as QuiesceSnapshot).branch, + head: (context.snap as QuiesceSnapshot).head, + reason: context.input.reason, + }), + onDone: { target: 'completed' }, + onError: { + target: 'cancelled', + actions: assign({ cause: 'failed' as CompactionCancelCause, error: ({ event }) => event.error }), + }, + }, + }, + completed: { + type: 'final', + entry: emit(({ context }) => ({ + type: 'compaction.completed' as const, + reason: context.input.reason, + branchId: context.branchId as string, + stats: context.stats as CompactionStats, + durationMs: Date.now() - context.startedAt, + originTurnId: context.originTurnId, + summary: context.summaryTelemetry, + })), + }, + cancelled: { + type: 'final', + entry: [ + ({ context }) => { + if (context.cause !== 'user-abort') { + deps.actor.send({ type: 'input.continue' }); + } + }, + emit(({ context }) => ({ + type: 'compaction.cancelled' as const, + reason: context.input.reason, + cause: context.cause as CompactionCancelCause, + error: context.cause === 'failed' ? context.error : undefined, + durationMs: Date.now() - context.startedAt, + originTurnId: context.originTurnId, + tokensBefore: context.snap?.tokensBefore, + })), + ], + }, + }, + output: ({ context }): CompactionMachineOutput => + context.cause === undefined + ? { + status: 'completed', + branchId: context.branchId as string, + stats: context.stats as CompactionStats, + } + : { status: 'cancelled', cause: context.cause, error: context.error }, + }); +} + +function cancelError(cause: 'cancelled' | 'user-abort'): CompactError { + return cause === 'cancelled' + ? new CompactError('cancelled', 'compaction was cancelled') + : new CompactError('aborted', 'compaction cancelled by user abort'); +} + +function defaultContinuation(reason: CompactionReason): UserMessage | undefined { + if (reason === 'manual') return undefined; + return compactionContinuationMessage(); +} diff --git a/packages/agent-core-v2/src/human/compaction/shape.ts b/packages/agent-core-v2/src/human/compaction/shape.ts new file mode 100644 index 00000000000..28ad91ec695 --- /dev/null +++ b/packages/agent-core-v2/src/human/compaction/shape.ts @@ -0,0 +1,205 @@ +import { estimateMessageTokens, estimateUsedContextTokens } from '#/agent/context-usage'; +import { inputSubmitted, messageAppended, turnEnded, turnStarted } from '#/agent/events'; +import type { QueuedPrompt } from '#/agent/slices'; +import { createUserEntry, type HistoryMessage, type UserEntry } from '#/agent/turn'; +import type { ExternalEvent } from '#/eventStore/events'; +import { createUserMessage, type UserMessage } from '#/llm/message'; + +import summaryPrefixTemplate from './compaction-summary-prefix.md?raw'; + +const COMPACTION_SUMMARY_PREFIX = summaryPrefixTemplate.trimEnd(); +const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000; +const COMPACT_USER_MESSAGE_HEAD_TOKENS = 2_000; + +export interface CompactionSeed { + events: ExternalEvent[]; + tokensAfter: number; + keptUserMessageCount: number; + keptHeadUserMessageCount?: number; +} + +interface CompactionUserSelection { + head: UserEntry[]; + tail: UserEntry[]; + elided: boolean; + omittedTokens: number; +} + +export function buildCompactionSeed(input: { + turnId: number; + history: readonly HistoryMessage[]; + summary: string; + queue: readonly QueuedPrompt[]; +}): CompactionSeed { + const compactable = input.history.filter(isKeptUserEntry); + const selection = selectCompactionUserMessages( + compactable, + COMPACT_USER_MESSAGE_MAX_TOKENS, + COMPACT_USER_MESSAGE_HEAD_TOKENS, + ); + const elision = selection.elided + ? createUserEntry(createUserMessage(elisionText(selection.omittedTokens)), { + source: 'compaction', + key: 'elision', + }) + : undefined; + const summaryEntry = createUserEntry(createUserMessage(summaryText(input.summary)), { + source: 'compaction', + key: 'summary', + }); + const kept: HistoryMessage[] = [ + ...selection.head, + ...(elision === undefined ? [] : [elision]), + ...selection.tail, + ]; + const seeded = [...kept, summaryEntry]; + const events: ExternalEvent[] = [ + turnStarted({ turnId: input.turnId }), + ...seeded.map((message) => messageAppended({ message })), + turnEnded({ turnId: input.turnId, outcome: 'done' }), + ...input.queue.map((item) => inputSubmitted({ id: item.id, message: item.message })), + ]; + return { + events, + tokensAfter: estimateUsedContextTokens(seeded), + keptUserMessageCount: selection.head.length + selection.tail.length, + keptHeadUserMessageCount: selection.elided ? selection.head.length : undefined, + }; +} + +export function compactionContinuationMessage(): UserMessage { + return createUserMessage( + wrapSystemReminder( + 'Context compaction is complete — continue the work that was in progress when it began.', + ), + ); +} + +function summaryText(summary: string): string { + const trimmed = summary.trim(); + return `${COMPACTION_SUMMARY_PREFIX}\n${trimmed.length > 0 ? trimmed : '(no summary available)'}`; +} + +function elisionText(omittedTokens: number): string { + return wrapSystemReminder( + `Some of this conversation's user messages were omitted here during compaction: the messages above this note are the oldest user input, the messages below are the most recent, and roughly ${String(omittedTokens)} tokens in between were dropped. The omitted content is covered by the compaction summary at the end of the conversation.`, + ); +} + +function wrapSystemReminder(content: string): string { + return `\n${content.trim()}\n`; +} + +function isKeptUserEntry(entry: HistoryMessage): entry is UserEntry { + if (entry.message.role !== 'user') return false; + if (entry.meta.source === 'compaction') return false; + return entry.meta.source === undefined || entry.meta.source === 'input'; +} + +function selectCompactionUserMessages( + messages: readonly UserEntry[], + maxTokens: number, + headTokens: number, +): CompactionUserSelection { + let totalTokens = 0; + for (const entry of messages) { + totalTokens += estimateMessageTokens(entry.message); + } + if (totalTokens <= maxTokens) { + return { head: [], tail: [...messages], elided: false, omittedTokens: 0 }; + } + + const headBudget = Math.min(Math.max(headTokens, 0), maxTokens); + const tail: UserEntry[] = []; + let tailRemaining = maxTokens - headBudget; + let headEndExclusive = messages.length; + let tailBoundaryDroppedPrefix: UserEntry | null = null; + for (let i = messages.length - 1; i >= 0 && tailRemaining > 0; i--) { + const entry = messages[i] as UserEntry; + const tokens = estimateMessageTokens(entry.message); + if (tokens <= tailRemaining) { + tail.push(entry); + tailRemaining -= tokens; + headEndExclusive = i; + continue; + } + const fullText = textOf(entry.message); + const keptSuffix = truncateTextToTokensFromEnd(fullText, tailRemaining); + tail.push(replaceEntryText(entry, keptSuffix)); + headEndExclusive = i; + const droppedPrefix = fullText.slice(0, fullText.length - keptSuffix.length); + if (droppedPrefix.length > 0) { + tailBoundaryDroppedPrefix = replaceEntryText(entry, droppedPrefix); + } + break; + } + tail.reverse(); + + const headCandidates = messages.slice(0, headEndExclusive); + if (tailBoundaryDroppedPrefix !== null) { + headCandidates.push(tailBoundaryDroppedPrefix); + } + const head: UserEntry[] = []; + let headRemaining = headBudget; + for (const entry of headCandidates) { + if (headRemaining <= 0) break; + const tokens = estimateMessageTokens(entry.message); + if (tokens <= headRemaining) { + head.push(entry); + headRemaining -= tokens; + continue; + } + head.push(replaceEntryText(entry, truncateTextToTokens(textOf(entry.message), headRemaining))); + break; + } + + let keptTokens = 0; + for (const entry of head) keptTokens += estimateMessageTokens(entry.message); + for (const entry of tail) keptTokens += estimateMessageTokens(entry.message); + return { head, tail, elided: true, omittedTokens: Math.max(0, totalTokens - keptTokens) }; +} + +function textOf(message: UserMessage): string { + let text = ''; + for (const part of message.content) { + if (part.type === 'text') { + text += part.text; + } + } + return text; +} + +function replaceEntryText(entry: UserEntry, text: string): UserEntry { + return { ...entry, message: { ...entry.message, content: [{ type: 'text', text }] } }; +} + +function truncateTextToTokens(text: string, maxTokens: number): string { + if (maxTokens <= 0) return ''; + let asciiCount = 0; + let nonAsciiCount = 0; + let end = 0; + for (const char of text) { + if ((char.codePointAt(0) as number) <= 127) { + asciiCount++; + } else { + nonAsciiCount++; + } + if (Math.ceil(asciiCount / 4) + nonAsciiCount > maxTokens) break; + end += char.length; + } + return text.slice(0, end); +} + +function truncateTextToTokensFromEnd(text: string, maxTokens: number): string { + if (maxTokens <= 0) return ''; + const chars = Array.from(text); + let tokens = 0; + let start = chars.length; + for (let i = chars.length - 1; i >= 0; i--) { + const code = chars[i]?.codePointAt(0) ?? 0; + tokens += code <= 127 ? 0.25 : 1; + if (Math.ceil(tokens) > maxTokens) break; + start = i; + } + return chars.slice(start).join(''); +} diff --git a/packages/agent-core-v2/src/human/compaction/summarize.ts b/packages/agent-core-v2/src/human/compaction/summarize.ts new file mode 100644 index 00000000000..030976977fe --- /dev/null +++ b/packages/agent-core-v2/src/human/compaction/summarize.ts @@ -0,0 +1,126 @@ +import { + createUserEntry, + type AssistantEntry, + type createTurnMachine, + type HistoryMessage, + type TurnOutput, +} from '#/agent/turn'; +import { createUserMessage, extractText } from '#/llm/message'; +import type { LlmRequestConfig } from '#/llm/requester/requester'; +import type { TokenUsage } from '#/llm/usage'; +import { createActor, waitFor } from '#/xstate2'; + +import instructionTemplate from './compaction-instruction.md?raw'; +import { CompactError, isShrinkableSummaryError } from './errors'; + +export interface SummaryOutcome { + text: string; + usage?: TokenUsage; + traceId?: string; + attempts: number; + droppedCount: number; +} + +export type Summarize = (input: { + history: readonly HistoryMessage[]; + instruction?: string; + signal: AbortSignal; +}) => Promise; + +export interface CreateSummarizeOptions { + request: LlmRequestConfig; + llm: () => ReturnType; + maxShrinkAttempts?: number; + timeoutMs?: number; +} + +export function createSummarize(options: CreateSummarizeOptions): Summarize { + const maxShrinkAttempts = options.maxShrinkAttempts ?? 3; + return async ({ history, instruction, signal }) => { + const instructionEntry = createUserEntry( + createUserMessage(compactionInstructionText(instruction)), + { source: 'input' }, + ); + let attemptHistory = [...history]; + for (let attempt = 0; ; attempt++) { + if (signal.aborted) { + throw new CompactError('aborted', 'compaction was aborted'); + } + const output = await runSummaryTurn(options, [...attemptHistory, instructionEntry], signal); + if (output.type === 'done') { + const entry = lastAssistantEntry(output.produced); + const text = entry === undefined ? undefined : extractText(entry.message); + if (entry !== undefined && text !== undefined && text.trim().length > 0) { + return { + text, + usage: entry.meta.usage, + traceId: entry.meta.headers?.['x-trace-id'], + attempts: attempt + 1, + droppedCount: history.length - attemptHistory.length, + }; + } + } + if (output.type === 'aborted') { + throw new CompactError('aborted', 'summary turn was aborted'); + } + const error = output.type === 'failed' ? output.error : undefined; + if ( + attempt + 1 >= maxShrinkAttempts || + attemptHistory.length <= 1 || + (error !== undefined && !isShrinkableSummaryError(error)) + ) { + throw new CompactError( + 'summary-failed', + `summary turn failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + attemptHistory = dropOldestAndLeadingToolResults(attemptHistory); + } + }; +} + +async function runSummaryTurn( + options: CreateSummarizeOptions, + history: readonly HistoryMessage[], + signal: AbortSignal, +): Promise { + const actor = createActor(options.llm(), { + input: { request: options.request, history, parentSignal: signal }, + }); + actor.start(); + try { + const snapshot = await waitFor(actor, (s) => s.status !== 'active', { + timeout: options.timeoutMs ?? 120_000, + }); + return snapshot.output as TurnOutput; + } finally { + actor.stop(); + } +} + +function lastAssistantEntry(produced: readonly HistoryMessage[]): AssistantEntry | undefined { + for (let i = produced.length - 1; i >= 0; i--) { + const entry = produced[i]; + if (entry !== undefined && entry.message.role === 'assistant') { + return entry as AssistantEntry; + } + } + return undefined; +} + +function compactionInstructionText(customInstruction?: string): string { + const custom = customInstruction?.trim() ?? ''; + const block = custom.length > 0 ? `\nOptional user instruction:\n${custom}\n` : ''; + return instructionTemplate.replace('${custom_instruction_block}', () => block).trimEnd(); +} + +function dropOldestAndLeadingToolResults( + history: readonly HistoryMessage[], +): HistoryMessage[] { + const rest = history.slice(1); + let start = 0; + while (start < rest.length && rest[start]?.message.role === 'tool') { + start++; + } + return rest.slice(start); +} diff --git a/packages/agent-core-v2/src/human/index.ts b/packages/agent-core-v2/src/human/index.ts index 508b2bc2d01..303d46f6a89 100644 --- a/packages/agent-core-v2/src/human/index.ts +++ b/packages/agent-core-v2/src/human/index.ts @@ -74,6 +74,10 @@ export * from './session/machine'; export * from './session/events'; export * from './session/slices'; export * from './session/stores'; +export * from './compaction/controller'; +export * from './compaction/errors'; +export * from './compaction/shape'; +export * from './compaction/summarize'; export * from './usage/usage'; export * from './usage/machine'; export * from './usage/plugin'; diff --git a/packages/agent-core-v2/src/human/session/events.ts b/packages/agent-core-v2/src/human/session/events.ts index a40131d2057..433c4a5e8d2 100644 --- a/packages/agent-core-v2/src/human/session/events.ts +++ b/packages/agent-core-v2/src/human/session/events.ts @@ -18,7 +18,12 @@ export type AgentClosed = ReturnType; export const agentSwitched = defineEvent({ type: 'agent.switched', - schema: z.object({ agentId: z.string(), branch: z.string(), reason: z.string().optional() }), + schema: z.object({ + agentId: z.string(), + branch: z.string(), + reason: z.string().optional(), + stats: z.record(z.string(), z.number()).optional(), + }), }); export type AgentSwitched = ReturnType; @@ -27,3 +32,29 @@ export const sessionMetaUpdated = defineEvent({ schema: z.object({ meta: z.unknown() }), }); export type SessionMetaUpdated = ReturnType; + +export const compactionStarted = defineEvent({ + type: 'compaction.started', + schema: z.object({ + agentId: z.string(), + reason: z.string(), + instruction: z.string().optional(), + }), +}); +export type CompactionStarted = ReturnType; + +export const compactionCompleted = defineEvent({ + type: 'compaction.completed', + schema: z.object({ agentId: z.string(), branch: z.string() }), +}); +export type CompactionCompleted = ReturnType; + +export const compactionCancelled = defineEvent({ + type: 'compaction.cancelled', + schema: z.object({ + agentId: z.string(), + cause: z.string(), + errorMessage: z.string().optional(), + }), +}); +export type CompactionCancelled = ReturnType; diff --git a/packages/agent-core-v2/src/human/session/stores.ts b/packages/agent-core-v2/src/human/session/stores.ts index aa3e7da82ac..d998d3c5dba 100644 --- a/packages/agent-core-v2/src/human/session/stores.ts +++ b/packages/agent-core-v2/src/human/session/stores.ts @@ -1,4 +1,5 @@ import { createEventStore, type EventStore } from '#/eventStore/eventStore'; +import type { ExternalEvent } from '#/eventStore/events'; import { journalFromBranch } from '#/eventStore/journal'; import { agentSlices, type AgentEventStore } from '#/agent/slices'; import type { StoreBackend } from '#/store/backend/backend'; @@ -123,11 +124,11 @@ export class SessionStores { throw new UndoError('insufficient', `cannot undo ${turns} turn(s): not enough turns`); } const from = undoForkRef(this.tree, cut.start); + if (from === undefined) { + throw new UndoError('insufficient', `cannot undo ${turns} turn(s): no earlier history`); + } const branchId = freshBranchName(this.tree, agentId); - const branch = - from === undefined - ? this.tree.createBranch(branchId) - : this.tree.createBranch(branchId, { from }); + const branch = this.tree.createBranch(branchId, { from }); await store.reset(journalFromBranch(branch, this.tree)); await ( await this.session() @@ -135,6 +136,31 @@ export class SessionStores { return { branchId }; } + async switchBranch( + agentId: string, + opts: { reason: string; stats?: Record; seed: readonly ExternalEvent[] }, + ): Promise<{ branchId: string }> { + const store = this.agents.get(agentId); + if (store === undefined) { + throw new StoreError('unknown-agent', `unknown agent '${agentId}'`); + } + const branchId = freshBranchName(this.tree, agentId); + const branch = this.tree.createBranch(branchId); + const journal = journalFromBranch(branch, this.tree); + const seedStore = await createEventStore({ journal, slices: agentSlices }); + try { + await seedStore.dispatch([...opts.seed]); + await seedStore.flush(); + } finally { + await seedStore.close(); + } + await store.reset(journal); + await (await this.session()).dispatch( + agentSwitched({ agentId, branch: branchId, reason: opts.reason, stats: opts.stats }), + ); + return { branchId }; + } + async flush(): Promise { await Promise.all([...this.agents.values()].map((store) => store.flush())); await this.sessionStore?.flush(); diff --git a/packages/agent-core-v2/src/human/test/agent/machine.test.ts b/packages/agent-core-v2/src/human/test/agent/machine.test.ts index 6916fe02d6c..75b9f315906 100644 --- a/packages/agent-core-v2/src/human/test/agent/machine.test.ts +++ b/packages/agent-core-v2/src/human/test/agent/machine.test.ts @@ -1139,6 +1139,7 @@ describe('agent machine input.abort', () => { it('aborts running turn tools and completes the transcript with aborted tool messages', async () => { const requester = createStubRequester([ createAssistantMessage([], [toolCall('call-1', 'slow_tool')]), + createAssistantMessage([{ type: 'text', text: 'resumed' }], []), ]); const signals: AbortSignal[] = []; const tools = stubTools(({ signal }) => { @@ -1171,6 +1172,20 @@ describe('agent machine input.abort', () => { 'assistant:', 'tool:aborted', ]); + + actor.send({ type: 'input.continue' }); + await waitFor( + actor, + (s) => s.matches('idle') && store.getState().history.length === 4, + { timeout: 5000 }, + ); + expect(rolesAndTexts(store.getState().history)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:aborted', + 'assistant:resumed', + ]); + expect(store.getState().turnIndex.nextTurnId).toBe(2); }); it('waits for the real outcome of a tool that settles after the abort signal', async () => { @@ -1380,6 +1395,131 @@ describe('agent machine input.abort', () => { }); }); +describe('agent machine input.pause/input.continue', () => { + it('gates queue drain while paused and resumes on continue', async () => { + const requester = createStubRequester([ + createAssistantMessage([{ type: 'text', text: 'hi there' }], []), + ]); + const store = await testStore(); + const actor = createActor(createTestAgentMachine([], requester), { + input: { request: { model }, store }, + }); + actor.start(); + actor.send({ type: 'input.pause' }); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(store.getState().queue).toHaveLength(1); + }); + expect(actor.getSnapshot().matches('running')).toBe(false); + + actor.send({ type: 'input.continue' }); + await waitFor( + actor, + (s) => s.matches('idle') && store.getState().history.length === 2, + { timeout: 5000 }, + ); + expect(rolesAndTexts(store.getState().history)).toEqual(['user:hi', 'assistant:hi there']); + expect(actor.getSnapshot().context.paused).toBe(false); + actor.stop(); + }); + + it('ends the turn at the acting boundary when paused and resumes with a new turn on continue', async () => { + let calls = 0; + const base = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'fast_tool')]), + createAssistantMessage([{ type: 'text', text: 'final' }], []), + ]); + const requester: LlmRequester = { + generate: (config, content, control) => { + calls += 1; + return base.generate(config, content, control); + }, + }; + let releaseTool: (() => void) | undefined; + const tools = stubTools( + () => + new Promise((resolve) => { + releaseTool = () => resolve({ content: [{ type: 'text', text: 'tool result' }] }); + }), + 'fast_tool', + ); + const store = await testStore(); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model }, store }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(actor.getSnapshot().context.turnTools['call-1']).toBeDefined(); + }); + actor.send({ type: 'input.pause' }); + (releaseTool as () => void)(); + + await waitFor( + actor, + (s) => s.matches('idle') && store.getState().history.length === 3, + { timeout: 5000 }, + ); + expect(rolesAndTexts(store.getState().history)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:tool result', + ]); + expect(calls).toBe(1); + expect(store.getState().turnIndex.nextTurnId).toBe(1); + + actor.send({ type: 'input.continue' }); + await waitFor( + actor, + (s) => s.matches('idle') && store.getState().history.length === 4, + { timeout: 5000 }, + ); + expect(rolesAndTexts(store.getState().history)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:tool result', + 'assistant:final', + ]); + expect(calls).toBe(2); + expect(store.getState().turnIndex.nextTurnId).toBe(2); + actor.stop(); + }); + + it('does not start a turn on continue when history ends with a plain assistant message', async () => { + let calls = 0; + const base = createStubRequester([ + createAssistantMessage([{ type: 'text', text: 'done' }], []), + ]); + const requester: LlmRequester = { + generate: (config, content, control) => { + calls += 1; + return base.generate(config, content, control); + }, + }; + const store = await testStore(); + const actor = createActor(createTestAgentMachine([], requester), { + input: { request: { model }, store }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + await waitFor( + actor, + (s) => s.matches('idle') && store.getState().history.length === 2, + { timeout: 5000 }, + ); + + actor.send({ type: 'input.continue' }); + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(actor.getSnapshot().matches('idle')).toBe(true); + expect(store.getState().history).toHaveLength(2); + expect(calls).toBe(1); + actor.stop(); + }); +}); + describe('agent machine max steps', () => { it('resets the step budget on drained input and fails only on pure tool-call continuation', async () => { let call = 0; diff --git a/packages/agent-core-v2/src/human/test/compaction/controller.test.ts b/packages/agent-core-v2/src/human/test/compaction/controller.test.ts new file mode 100644 index 00000000000..5476f8d2a5c --- /dev/null +++ b/packages/agent-core-v2/src/human/test/compaction/controller.test.ts @@ -0,0 +1,541 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createAgentMachine, type AgentMachineContext } from '#/agent/machine'; +import { messageAppended } from '#/agent/events'; +import type { AgentEventStore } from '#/agent/slices'; +import { createTurnMachine, createUserEntry, type TurnBeforeStep } from '#/agent/turn'; +import { createCompactionController, type CompactionEvent } from '#/compaction/controller'; +import type { Summarize, SummaryOutcome } from '#/compaction/summarize'; +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { createUserMessage, extractText } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import { createLlmMachine } from '#/llm/requester/machine'; +import type { LlmRequestConfig, LlmRequester } from '#/llm/requester/requester'; +import { SessionStores } from '#/session/stores'; +import { MemoryBackend } from '#/store/backend/memory'; +import { TreeStore } from '#/store/store'; +import type { Tree } from '#/store/tree'; +import { createActor, waitFor, type ActorRefFrom } from '#/xstate2'; + +const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; + +type AgentActor = ActorRefFrom>; + +interface TestEnv { + backend: MemoryBackend; + tree: Tree; + stores: SessionStores; +} + +async function testEnv(): Promise { + const backend = new MemoryBackend(); + const store = await TreeStore.open(backend, {}); + const tree = await store.tree('sess'); + return { backend, tree, stores: new SessionStores(tree, backend) }; +} + +interface BeforeStepHook { + current?: TurnBeforeStep; +} + +function startAgent( + store: AgentEventStore, + requester: LlmRequester, + beforeStep?: BeforeStepHook, + request?: Partial, +): AgentActor { + const actor = createActor( + createAgentMachine({ + tools: [], + turnActor: createTurnMachine(createLlmMachine({ requester }), { + onBeforeStep: (context) => beforeStep?.current?.(context), + }), + }), + { input: { request: { model, ...request }, store } }, + ); + actor.start(); + return actor; +} + +function createEchoRequester(): LlmRequester { + return { + generate: (_config, { messages }, { onEvent }) => { + const last = messages.at(-1); + const text = last !== undefined && last.role === 'user' ? extractText(last) : ''; + onEvent?.({ type: 'llm.streaming.part', part: { type: 'text', text: `echo:${text}` } }); + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + }, + }; +} + +function createOverflowRequester(): LlmRequester { + return { + generate: (_config, _content, { onEvent }) => { + onEvent?.({ + type: 'llm.failed.remote', + error: { + kind: 'context_overflow', + message: 'maximum context length exceeded', + statusCode: 400, + requestId: null, + retryAfterMs: null, + headers: null, + }, + }); + return Promise.resolve(); + }, + }; +} + +interface ControllerHarness { + controller: ReturnType; + events: CompactionEvent[]; + summarizeCalls: { historyLength: number; instruction?: string }[]; +} + +function startController( + env: TestEnv, + actor: AgentActor, + overrides?: Partial[0]>, +): ControllerHarness { + const events: CompactionEvent[] = []; + const summarizeCalls: { historyLength: number; instruction?: string }[] = []; + const summarize: Summarize = async ({ history, instruction }) => { + summarizeCalls.push({ historyLength: history.length, instruction }); + return { text: 'SUMMARY TEXT', attempts: 1, droppedCount: 0 }; + }; + const controller = createCompactionController({ + agentId: 'main', + actor, + stores: env.stores, + summarize, + budget: { maxContextTokens: () => 2000, triggerRatio: 0.85 }, + onEvent: (event) => events.push(event), + ...overrides, + }); + return { controller, events, summarizeCalls }; +} + +function historyTexts(store: AgentEventStore): string[] { + return store.getState().history.map((entry) => extractText(entry.message)); +} + +describe('compaction controller manual', () => { + it('compacts an idle agent onto a fresh branch and blocks undo across the switch', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + const actor = startAgent(main, createEchoRequester()); + actor.send({ type: 'input.submit', message: createUserMessage('first') }); + await waitFor(actor, (s) => s.matches('idle') && main.getState().history.length === 2, { + timeout: 5000, + }); + const willCompactInputs: { tokenCount: number }[] = []; + const harness = startController(env, actor, { + onWillCompact: (input) => { + willCompactInputs.push(input); + }, + }); + + const result = await harness.controller.compact(); + + expect(result.branchId).toBe('main~2'); + expect(main.ref.branch).toBe('main~2'); + const texts = historyTexts(main); + expect(texts).toHaveLength(2); + expect(texts[0]).toBe('first'); + expect(texts[1]).toContain('SUMMARY TEXT'); + expect(main.getState().turnIndex.turns).toHaveLength(1); + expect(main.getState().turnIndex.nextTurnId).toBe(2); + expect(env.tree.openBranch('main~2').header.parentBranch).toBeUndefined(); + expect((await env.stores.session()).getState().roster.agents['main']).toBe('main~2'); + await expect(env.stores.undo('main', 1)).rejects.toMatchObject({ reason: 'insufficient' }); + const sessionBranch = env.tree.openBranch('_session'); + const sessionTypes: string[] = []; + for (let seq = 0; seq <= (sessionBranch.head ?? -1); seq++) { + const entry = sessionBranch.entryAt(seq); + if (entry !== null) sessionTypes.push(entry.type); + } + expect(sessionTypes).toEqual([ + 'agent.opened', + 'compaction.started', + 'agent.switched', + 'compaction.completed', + ]); + expect(harness.controller.status()).toEqual({ phase: 'idle' }); + expect(harness.summarizeCalls).toEqual([{ historyLength: 2, instruction: undefined }]); + expect(harness.events.map((event) => event.type)).toEqual([ + 'compaction.started', + 'compaction.completed', + ]); + const completed = harness.events.at(-1); + expect(completed?.type === 'compaction.completed' && completed.reason === 'manual').toBe(true); + expect( + completed?.type === 'compaction.completed' && + completed.originTurnId === undefined && + completed.summary?.attempts === 1 && + completed.summary?.droppedCount === 0, + ).toBe(true); + expect(willCompactInputs).toHaveLength(1); + expect(willCompactInputs[0]?.tokenCount).toBeGreaterThan(0); + expect(actor.getSnapshot().matches('idle')).toBe(true); + expect((actor.getSnapshot().context as AgentMachineContext).messages).toHaveLength(2); + await env.stores.flush(); + expect(main.getState().history).toHaveLength(2); + + harness.controller.dispose(); + actor.stop(); + }); + + it('pauses a running turn at the step boundary and preserves queued inputs through the switch', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + let release: (() => void) | undefined; + let first = true; + const requester: LlmRequester = { + generate: (_config, { messages }, { onEvent }) => { + const last = messages.at(-1); + const text = last !== undefined && last.role === 'user' ? extractText(last) : ''; + const respond = (): void => { + onEvent?.({ type: 'llm.streaming.part', part: { type: 'text', text: `echo:${text}` } }); + onEvent?.({ type: 'llm.done' }); + }; + if (!first) { + respond(); + return Promise.resolve(); + } + first = false; + return new Promise((resolve) => { + release = () => { + respond(); + resolve(); + }; + }); + }, + }; + const actor = startAgent(main, requester); + actor.send({ type: 'input.submit', message: createUserMessage('first') }); + await waitFor(actor, (s) => s.matches('running'), { timeout: 5000 }); + actor.send({ type: 'input.submit', message: createUserMessage('q1') }); + actor.send({ type: 'input.submit', message: createUserMessage('q2') }); + await vi.waitFor(() => expect(main.getState().queue).toHaveLength(2), { timeout: 5000 }); + const harness = startController(env, actor); + + const compactPromise = harness.controller.compact(); + await vi.waitFor(() => expect(actor.getSnapshot().context.paused).toBe(true), { timeout: 5000 }); + (release as () => void)(); + + const result = await compactPromise; + expect(result.branchId).toBe('main~2'); + await waitFor(actor, (s) => s.matches('idle') && main.getState().history.length === 6, { + timeout: 5000, + }); + + const texts = historyTexts(main); + expect(texts[0]).toBe('first'); + expect(texts[1]).toContain('SUMMARY TEXT'); + expect(texts.slice(2)).toEqual(['q1', 'echo:q1', 'q2', 'echo:q2']); + expect(main.getState().turnIndex.nextTurnId).toBe(4); + expect(harness.summarizeCalls[0]?.historyLength).toBe(2); + + harness.controller.dispose(); + actor.stop(); + }); + + it('merges inputs submitted and steered during summarization into the new branch', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + const actor = startAgent(main, createEchoRequester()); + actor.send({ type: 'input.submit', message: createUserMessage('first') }); + await waitFor(actor, (s) => s.matches('idle') && main.getState().history.length === 2, { + timeout: 5000, + }); + let resolveSummary: ((outcome: SummaryOutcome) => void) | undefined; + let summaryCalled = false; + const summarize: Summarize = () => { + summaryCalled = true; + return new Promise((resolve) => { + resolveSummary = resolve; + }); + }; + const harness = startController(env, actor, { summarize }); + + const compactPromise = harness.controller.compact(); + await vi.waitFor(() => expect(summaryCalled).toBe(true), { timeout: 5000 }); + expect(harness.controller.status().phase).toBe('summarizing'); + actor.send({ type: 'input.submit', id: 's1', message: createUserMessage('late') }); + actor.send({ type: 'input.submit', message: createUserMessage('queued') }); + await vi.waitFor(() => expect(main.getState().queue).toHaveLength(2), { timeout: 5000 }); + actor.send({ type: 'input.steer', id: 's1' }); + await vi.waitFor(() => expect(main.getState().notifications).toHaveLength(1), { timeout: 5000 }); + (resolveSummary as (outcome: SummaryOutcome) => void)({ + text: 'MERGED SUMMARY', + attempts: 1, + droppedCount: 0, + }); + + const result = await compactPromise; + expect(result.branchId).toBe('main~2'); + await waitFor(actor, (s) => s.matches('idle') && main.getState().history.length === 5, { + timeout: 5000, + }); + + expect(historyTexts(main)).toEqual([ + 'first', + expect.stringContaining('MERGED SUMMARY'), + 'late', + 'queued', + 'echo:queued', + ]); + expect(main.getState().turnIndex.nextTurnId).toBe(3); + expect(harness.events.map((event) => event.type)).toEqual([ + 'compaction.started', + 'compaction.completed', + ]); + + harness.controller.dispose(); + actor.stop(); + }); + + it('cancels an in-flight compaction via cancel() and releases the pause', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + const actor = startAgent(main, createEchoRequester()); + actor.send({ type: 'input.submit', message: createUserMessage('first') }); + await waitFor(actor, (s) => s.matches('idle') && main.getState().history.length === 2, { + timeout: 5000, + }); + const summarize: Summarize = ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + const harness = startController(env, actor, { summarize }); + + const compactPromise = harness.controller.compact(); + await vi.waitFor(() => expect(harness.controller.status().phase).toBe('summarizing'), { + timeout: 5000, + }); + actor.send({ type: 'input.submit', message: createUserMessage('while-compacting') }); + harness.controller.cancel(); + + await expect(compactPromise).rejects.toMatchObject({ code: 'cancelled' }); + await waitFor(actor, (s) => s.matches('idle') && main.getState().history.length === 4, { + timeout: 5000, + }); + expect(main.ref.branch).toBe('main'); + expect(env.tree.has('main~2')).toBe(false); + expect(historyTexts(main)).toEqual([ + 'first', + 'echo:first', + 'while-compacting', + 'echo:while-compacting', + ]); + expect(harness.events.map((event) => event.type)).toEqual([ + 'compaction.started', + 'compaction.cancelled', + ]); + const cancelled = harness.events.at(-1); + expect(cancelled?.type === 'compaction.cancelled' && cancelled.cause === 'cancelled').toBe(true); + expect( + cancelled?.type === 'compaction.cancelled' && + typeof cancelled.tokensBefore === 'number' && + cancelled.tokensBefore > 0, + ).toBe(true); + expect(harness.controller.status()).toEqual({ phase: 'idle' }); + + harness.controller.dispose(); + actor.stop(); + }); + + it('cancels the compaction when the user aborts during quiesce and stays paused until resumed', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + let first = true; + const requester: LlmRequester = { + generate: (_config, { messages }, { onEvent }) => { + const last = messages.at(-1); + const text = last !== undefined && last.role === 'user' ? extractText(last) : ''; + if (!first) { + onEvent?.({ type: 'llm.streaming.part', part: { type: 'text', text: `echo:${text}` } }); + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + } + first = false; + return new Promise(() => undefined); + }, + }; + const actor = startAgent(main, requester); + actor.send({ type: 'input.submit', message: createUserMessage('first') }); + await waitFor(actor, (s) => s.matches('running'), { timeout: 5000 }); + const harness = startController(env, actor); + + const compactPromise = harness.controller.compact(); + await vi.waitFor(() => expect(actor.getSnapshot().context.paused).toBe(true), { timeout: 5000 }); + expect(harness.controller.status().phase).toBe('quiescing'); + actor.send({ type: 'input.abort' }); + + await expect(compactPromise).rejects.toMatchObject({ code: 'aborted' }); + await waitFor(actor, (s) => s.matches('idle'), { timeout: 5000 }); + expect(harness.events.map((event) => event.type)).toEqual([ + 'compaction.started', + 'compaction.blocked', + 'compaction.cancelled', + ]); + const cancelled = harness.events.at(-1); + expect(cancelled?.type === 'compaction.cancelled' && cancelled.cause === 'user-abort').toBe(true); + + actor.send({ type: 'input.submit', message: createUserMessage('later') }); + await vi.waitFor(() => expect(main.getState().queue).toHaveLength(1), { timeout: 5000 }); + expect(actor.getSnapshot().matches('idle')).toBe(true); + expect(actor.getSnapshot().context.paused).toBe(true); + expect(historyTexts(main)).toEqual(['first']); + + actor.send({ type: 'input.continue' }); + await waitFor(actor, (s) => s.matches('idle') && main.getState().history.length === 3, { + timeout: 5000, + }); + expect(historyTexts(main)).toEqual(['first', 'later', 'echo:later']); + expect(harness.controller.status()).toEqual({ phase: 'idle' }); + + harness.controller.dispose(); + actor.stop(); + }); + + it('cancels the compaction when non-input entries land on the branch during summarization', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + const actor = startAgent(main, createEchoRequester()); + actor.send({ type: 'input.submit', message: createUserMessage('first') }); + await waitFor(actor, (s) => s.matches('idle') && main.getState().history.length === 2, { + timeout: 5000, + }); + let resolveSummary: ((outcome: SummaryOutcome) => void) | undefined; + let summaryCalled = false; + const summarize: Summarize = () => { + summaryCalled = true; + return new Promise((resolve) => { + resolveSummary = resolve; + }); + }; + const harness = startController(env, actor, { summarize }); + + const compactPromise = harness.controller.compact(); + await vi.waitFor(() => expect(summaryCalled).toBe(true), { timeout: 5000 }); + await main.dispatch([ + messageAppended({ message: createUserEntry(createUserMessage('foreign'), { source: 'input' }) }), + ]); + (resolveSummary as (outcome: SummaryOutcome) => void)({ text: 'TOO LATE', attempts: 1, droppedCount: 0 }); + + await expect(compactPromise).rejects.toMatchObject({ code: 'drift' }); + expect(main.ref.branch).toBe('main'); + expect(env.tree.has('main~2')).toBe(false); + expect(harness.events.map((event) => event.type)).toEqual([ + 'compaction.started', + 'compaction.cancelled', + ]); + const cancelled = harness.events.at(-1); + expect(cancelled?.type === 'compaction.cancelled' && cancelled.cause === 'drift').toBe(true); + + harness.controller.dispose(); + actor.stop(); + }); +}); + +describe('compaction controller auto', () => { + it('blocks an over-budget step before the request is sent, compacts, then resumes', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + const seen: string[] = []; + const requester: LlmRequester = { + generate: (_config, { messages }, { onEvent }) => { + const last = messages.at(-1); + const text = last !== undefined && last.role === 'user' ? extractText(last) : ''; + seen.push(text); + const reply = text === 'big' ? 'R'.repeat(2600) : `echo:${text}`; + onEvent?.({ type: 'llm.streaming.part', part: { type: 'text', text: reply } }); + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + }, + }; + const beforeStep: BeforeStepHook = {}; + const actor = startAgent(main, requester, beforeStep, { systemPrompt: 'S'.repeat(4400) }); + const harness = startController(env, actor); + beforeStep.current = harness.controller.onBeforeStep; + + actor.send({ type: 'input.submit', message: createUserMessage('big') }); + await waitFor(actor, (s) => s.matches('idle') && main.getState().history.length === 2, { + timeout: 5000, + }); + expect(seen).toEqual(['big']); + + actor.send({ type: 'input.submit', message: createUserMessage('next') }); + await vi.waitFor( + () => { + expect(harness.events.filter((event) => event.type === 'compaction.completed')).toHaveLength(1); + }, + { timeout: 5000 }, + ); + await waitFor(actor, (s) => s.matches('idle') && main.getState().history.length === 5, { + timeout: 5000, + }); + + expect(seen).toHaveLength(2); + expect(seen[1]).toContain('Context compaction is complete'); + expect(main.ref.branch).toBe('main~2'); + const texts = historyTexts(main); + expect(texts[0]).toBe('big'); + expect(texts[1]).toBe('next'); + expect(texts[2]).toContain('SUMMARY TEXT'); + expect(texts[3]).toContain('Context compaction is complete'); + expect(texts[4]).toContain('echo:'); + expect(main.getState().turnIndex.nextTurnId).toBe(4); + expect(harness.events.map((event) => event.type)).toEqual([ + 'compaction.started', + 'compaction.blocked', + 'compaction.completed', + ]); + const completed = harness.events.at(-1); + expect(completed?.type === 'compaction.completed' && completed.originTurnId === 1).toBe(true); + await env.stores.flush(); + expect(main.getState().history).toHaveLength(5); + expect(harness.events.filter((event) => event.type === 'compaction.started')).toHaveLength(1); + + harness.controller.dispose(); + actor.stop(); + }); + + it('recovers from overflow turns up to the attempt cap, then surfaces the failure', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + const beforeStep: BeforeStepHook = {}; + const actor = startAgent(main, createOverflowRequester(), beforeStep); + let failedCount = 0; + actor.on('turn.failed', () => { + failedCount += 1; + }); + const harness = startController(env, actor, { maxAutoAttempts: 2 }); + beforeStep.current = harness.controller.onBeforeStep; + + actor.send({ type: 'input.submit', message: createUserMessage('go') }); + await vi.waitFor(() => expect(failedCount).toBe(3), { timeout: 5000 }); + await vi.waitFor( + () => { + expect(harness.events.filter((event) => event.type === 'compaction.completed')).toHaveLength(2); + }, + { timeout: 5000 }, + ); + await waitFor(actor, (s) => s.matches('idle'), { timeout: 5000 }); + + expect(harness.summarizeCalls).toHaveLength(2); + expect(main.ref.branch).toBe('main~3'); + expect(harness.events.map((event) => event.type)).toEqual([ + 'compaction.started', + 'compaction.completed', + 'compaction.started', + 'compaction.completed', + ]); + + harness.controller.dispose(); + actor.stop(); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/session/stores.test.ts b/packages/agent-core-v2/src/human/test/session/stores.test.ts index a6473c6e136..e076ca6787c 100644 --- a/packages/agent-core-v2/src/human/test/session/stores.test.ts +++ b/packages/agent-core-v2/src/human/test/session/stores.test.ts @@ -6,7 +6,8 @@ import { createUserMessage, extractText } from '#/llm/message'; import type { LlmModel } from '#/llm/model'; import type { LlmRequester } from '#/llm/requester/requester'; import { createAgentMachine } from '#/agent/machine'; -import { createTurnMachine } from '#/agent/turn'; +import { inputSubmitted, messageAppended, turnEnded, turnStarted } from '#/agent/events'; +import { createTurnMachine, createUserEntry } from '#/agent/turn'; import type { AgentEventStore } from '#/agent/slices'; import { SessionStores } from '#/session/stores'; import { MemoryBackend } from '#/store/backend/memory'; @@ -220,3 +221,59 @@ describe('SessionStores reopen', () => { actor2.stop(); }); }); + +describe('SessionStores switchBranch', () => { + it('seeds a fresh branch, resets the store, and blocks undo across the switch', async () => { + const env = await testEnv(); + const main = await env.stores.open('main'); + const actor = startAgent(main); + await runTurn(actor, main, 'first', 2); + actor.stop(); + const switched: { branch: string; reason?: string; stats?: Record }[] = []; + (await env.stores.session()).subscribe((_state, cause) => { + if (cause.kind === 'event' && cause.event.type === 'agent.switched') { + const event = cause.event as { branch: string; reason?: string; stats?: Record }; + switched.push({ branch: event.branch, reason: event.reason, stats: event.stats }); + } + }); + + const result = await env.stores.switchBranch('main', { + reason: 'compaction', + stats: { compactedCount: 2, tokensBefore: 10, tokensAfter: 5 }, + seed: [ + turnStarted({ turnId: 1 }), + messageAppended({ message: createUserEntry(createUserMessage('seed-user')) }), + messageAppended({ message: createUserEntry(createUserMessage('seed-summary')) }), + turnEnded({ turnId: 1, outcome: 'done' }), + inputSubmitted({ message: createUserMessage('queued') }), + ], + }); + + expect(result.branchId).toBe('main~2'); + expect(main.ref.branch).toBe('main~2'); + expect(historyTexts(main)).toEqual(['seed-user', 'seed-summary']); + expect(main.getState().turnIndex).toEqual({ + turns: [{ turnId: 1, start: { branch: 'main~2', seq: 0 }, end: { branch: 'main~2', seq: 3 } }], + nextTurnId: 2, + }); + expect(main.getState().queue).toEqual([{ id: undefined, message: createUserMessage('queued') }]); + expect(env.tree.openBranch('main~2').header.parentBranch).toBeUndefined(); + expect(switched).toEqual([ + { + branch: 'main~2', + reason: 'compaction', + stats: { compactedCount: 2, tokensBefore: 10, tokensAfter: 5 }, + }, + ]); + await expect(env.stores.undo('main', 1)).rejects.toMatchObject({ reason: 'insufficient' }); + + const actor2 = startAgent(main); + await waitFor(actor2, (s) => s.matches('idle') && main.getState().history.length === 4, { + timeout: 5000, + }); + expect(historyTexts(main)).toEqual(['seed-user', 'seed-summary', 'queued', 'echo:queued']); + expect(main.getState().turnIndex.nextTurnId).toBe(3); + + actor2.stop(); + }); +}); From 469d1fb1f76aa8a6a31e67f4e542922ecc053f9a Mon Sep 17 00:00:00 2001 From: Haozhe Date: Thu, 10 Sep 2026 19:06:20 +0800 Subject: [PATCH 2/8] feat(kap-server): add steer flag to submit a prompt directly into the running turn (#3705) --- docs/en/reference/server-api.md | 5 +- docs/zh/reference/server-api.md | 5 +- .../kap-server/src/protocol/rest-prompt.ts | 1 + packages/kap-server/src/routes/prompts.ts | 13 ++++ packages/kap-server/test/prompts.test.ts | 71 ++++++++++++++++--- 5 files changed, 82 insertions(+), 13 deletions(-) diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index f38aa633d7a..5881fa6122a 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -1022,7 +1022,7 @@ On success, `data` is `{ active, queued }`: `active` is the running prompt (`nul #### `POST /api/v1/sessions/{session_id}/prompts` -Submits a user prompt to the session. Media references are validated first, then the optional overrides are applied to the target agent — `profile` (bound together with `model` / `thinking`), then `model`, `thinking`, `permission_mode`, and `disabled_tools` — and the prompt is enqueued; the response returns as soon as the prompt is accepted, without waiting for the turn. With `skills`, the prompt runs as a bundled skill activation instead of a plain user prompt. +Submits a user prompt to the session. Media references are validated first, then the optional overrides are applied to the target agent — `profile` (bound together with `model` / `thinking`), then `model`, `thinking`, `permission_mode`, and `disabled_tools` — and the prompt is enqueued; the response returns as soon as the prompt is accepted, without waiting for the turn. With `steer: true`, a prompt submitted while the session is busy is steered directly into the running turn instead of waiting in the queue — the one-call form of submitting and then calling `POST /api/v1/sessions/{session_id}/prompts:steer`; on an idle session it starts a new turn as usual. With `skills`, the prompt runs as a bundled skill activation instead of a plain user prompt. | Parameter | In | Type | Description | | --- | --- | --- | --- | @@ -1030,6 +1030,7 @@ Submits a user prompt to the session. Media references are validated first, then | `content` | body | array | **Required.** Non-empty array of content parts; variants below | | `agent_id` | body | string | Target agent. Default the main agent | | `prompt_id` | body | string | Client-chosen prompt id for idempotent submission; an id already reserved by an in-flight prompt fails `40927`, one that has already completed fails `40903`. Cannot be combined with `skills` | +| `steer` | body | boolean | When `true`, steer the prompt directly into the running turn on a busy session instead of queueing it; a no-op on an idle session, where the prompt starts a new turn as usual. Cannot be combined with `skills` | | `skills` | body | array | Bundled skill activations, at least 1 entry of `{ name, args? }`; every skill must exist and be user-activatable | | `profile` | body | string | Agent profile to bind before submitting | | `model` | body | string | Model alias to switch the agent to | @@ -1049,7 +1050,7 @@ The schema also accepts the `tool_use`, `tool_result`, and `thinking` parts of t On success, `data` is the accepted prompt `{ prompt_id, user_message_id, status, content, created_at }`. -- `40001`: validation failure — for example `prompt_id` combined with `skills`, or an unknown `profile` +- `40001`: validation failure — for example `prompt_id` or `steer` combined with `skills`, or an unknown `profile` - `40110`: no provider configured yet — finish login first - `40111`: the resolved provider has no credential (`details.provider_id`) - `40112`: the provider's credential was rejected (`details.provider_id`) diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 530dfd3cb4b..6f6d8175827 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -1022,7 +1022,7 @@ main agent 的实时状态汇总;读取它会在会话为冷态时将其恢复 #### `POST /api/v1/sessions/{session_id}/prompts` -向会话提交一条用户提示词。先校验媒体引用,然后把可选的覆盖项应用到目标 Agent——`profile`(与 `model` / `thinking` 一起绑定),接着是 `model`、`thinking`、`permission_mode` 和 `disabled_tools`——随后提示词入队;响应在提示词被接受后立即返回,不等待轮次执行。提供 `skills` 时,提示词以打包的 Skill 激活方式运行,而不是普通用户提示词。 +向会话提交一条用户提示词。先校验媒体引用,然后把可选的覆盖项应用到目标 Agent——`profile`(与 `model` / `thinking` 一起绑定),接着是 `model`、`thinking`、`permission_mode` 和 `disabled_tools`——随后提示词入队;响应在提示词被接受后立即返回,不等待轮次执行。提供 `steer: true` 时,会话忙碌期间提交的提示词直接插入进行中的轮次,而不是在队列中等待——相当于提交后再调用 `POST /api/v1/sessions/{session_id}/prompts:steer` 的一次调用形式;会话空闲时则照常开启新轮次。提供 `skills` 时,提示词以打包的 Skill 激活方式运行,而不是普通用户提示词。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | @@ -1030,6 +1030,7 @@ main agent 的实时状态汇总;读取它会在会话为冷态时将其恢复 | `content` | body | array | **必填。** 非空的内容块数组;变体见下 | | `agent_id` | body | string | 目标 Agent。默认为 main agent | | `prompt_id` | body | string | 客户端选定的提示词 id,用于幂等提交;已被进行中提示词占用的 id 返回 `40927`,已完成的返回 `40903`。不能与 `skills` 同用 | +| `steer` | body | boolean | 为 `true` 时,会话忙碌期间提交的提示词直接插入进行中的轮次而不是排队等待;会话空闲时为无操作,提示词照常开启新轮次。不能与 `skills` 同用 | | `skills` | body | array | 打包的 Skill 激活,至少 1 个 `{ name, args? }` 条目;每个 Skill 必须存在且可由用户激活 | | `profile` | body | string | 提交前要绑定的 Agent 档案 | | `model` | body | string | 要切换到的模型别名 | @@ -1049,7 +1050,7 @@ schema 还接受共享消息格式中的 `tool_use`、`tool_result` 和 `thinkin 成功时,`data` 为被接受的提示词 `{ prompt_id, user_message_id, status, content, created_at }`。 -- `40001`:校验失败——例如 `prompt_id` 与 `skills` 同用,或未知的 `profile` +- `40001`:校验失败——例如 `prompt_id` 或 `steer` 与 `skills` 同用,或未知的 `profile` - `40110`:尚未配置供应商——请先完成登录 - `40111`:解析出的供应商没有凭据(`details.provider_id`) - `40112`:供应商的凭据被拒绝(`details.provider_id`) diff --git a/packages/kap-server/src/protocol/rest-prompt.ts b/packages/kap-server/src/protocol/rest-prompt.ts index 96207416c60..fe8230337f2 100644 --- a/packages/kap-server/src/protocol/rest-prompt.ts +++ b/packages/kap-server/src/protocol/rest-prompt.ts @@ -30,6 +30,7 @@ export const promptSubmissionSchema = z.object({ goal_control: z.enum(['pause', 'resume', 'cancel']).optional(), disabled_tools: z.array(z.string()).optional(), prompt_id: z.string().min(1).optional(), + steer: z.boolean().optional(), skills: z.array(promptSkillActivationSchema).min(1).optional(), }); export type PromptSubmission = z.infer; diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts index 0857fd5dca7..60006ec8965 100644 --- a/packages/kap-server/src/routes/prompts.ts +++ b/packages/kap-server/src/routes/prompts.ts @@ -238,6 +238,12 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { 'prompt_id cannot be combined with a bundled skill submission', ); } + if (req.body.steer === true) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'steer cannot be combined with a bundled skill submission', + ); + } await assertActivatableSkills( session.accessor.get(ISessionSkillCatalog), req.body.skills, @@ -354,6 +360,13 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { origin: { kind: 'user', attachments: promptAttachments }, }); enqueued = true; + if (req.body.steer === true && handle.state === 'pending') { + try { + await resolved.prompt.steer([handle.id]); + } catch (error) { + if (!(isError2(error) && error.code === ErrorCodes.PROMPT_NOT_FOUND)) throw error; + } + } const staging = preparedMedia; void Promise.race([handle.launched, handle.completion]).then( () => staging?.discard(), diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index 772fdfcedc4..0a12f4b3859 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -9,6 +9,7 @@ import { IAgentTitlePromptSource, IAgentContextMemoryService, IAgentLifecycleService, + IAgentLoopService, IAgentPermissionModeService, IAgentProfileService, IAgentStateService, @@ -305,6 +306,55 @@ describe('server-v2 /api/v1 prompts', () => { expect(Array.isArray(list.body.data.queued)).toBe(true); }); + it('steers a submission directly into the running turn when steer is true', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + await setSessionModel(id, 'stub'); + const session = getLiveSessionById(server!.core.accessor, id)!; + const agent = session.accessor.get(IAgentLifecycleService).handleOf('main')!; + const loop = agent.accessor.get(IAgentLoopService); + + let releaseStep!: () => void; + const stepGate = new Promise((resolve) => { + releaseStep = resolve; + }); + let stepHeld = false; + const hook = loop.hooks.onWillBeginStep.register('test-hold-step', async (_context, next) => { + stepHeld = true; + await stepGate; + await next(); + }); + try { + const first = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'first' }], + }); + expect(first.body.code).toBe(0); + expect(first.body.data.status).toBe('running'); + await vi.waitFor(() => { + expect(stepHeld).toBe(true); + }, { timeout: 10_000 }); + + const steered = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'steer me' }], + steer: true, + }); + expect(steered.body.code).toBe(0); + expect(steered.body.data.status).toBe('running'); + expect(steered.body.data.prompt_id).not.toBe(first.body.data.prompt_id); + + const list = await call<{ active: PromptItemWire | null; queued: PromptItemWire[] }>( + 'GET', + `/api/v1/sessions/${id}/prompts`, + ); + expect(list.body.code).toBe(0); + expect(list.body.data.active?.prompt_id).toBe(first.body.data.prompt_id); + expect(list.body.data.queued).toEqual([]); + } finally { + releaseStep(); + hook.dispose(); + } + }); + it('accepts a prompt-carried model when default_model is not configured', async () => { await writeConfigToml(home as string, PROMPT_TOML_NO_DEFAULT); const id = await createSession(home as string); @@ -566,7 +616,7 @@ describe('server-v2 /api/v1 prompts', () => { expect(session!.accessor.get(IAgentLifecycleService).handleOf('main')).toBeUndefined(); }); - it('rejects a bundled prompt_id combination before any override or agent materialization', async () => { + it('rejects a bundled prompt_id or steer combination before any override or agent materialization', async () => { const id = await createSession(home as string); const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { @@ -577,6 +627,14 @@ describe('server-v2 /api/v1 prompts', () => { }); expect(submitted.body.code).toBe(40001); + const steered = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'Review this change.' }], + permission_mode: 'yolo', + steer: true, + skills: [{ name: 'update-config' }], + }); + expect(steered.body.code).toBe(40001); + const session = getLiveSessionById(server!.core.accessor, id); expect(session!.accessor.get(IAgentLifecycleService).handleOf('main')).toBeUndefined(); }); @@ -1568,7 +1626,7 @@ describe('server-v2 /api/v1 prompts', () => { expect(body.code).toBe(40001); }); - it('returns 40402 when aborting a prompt that already settled', async () => { + it('returns 40402 when aborting a prompt that already settled or never existed', async () => { const id = await createSession(home as string); await createMainAgent(id); @@ -1582,17 +1640,12 @@ describe('server-v2 /api/v1 prompts', () => { `/api/v1/sessions/${id}/prompts/${promptId}:abort`, ); expect(aborted.body.code).toBe(40402); - }); - - it('returns 40402 when aborting an unknown prompt', async () => { - const id = await createSession(home as string); - await createMainAgent(id); - const { body } = await call( + const unknown = await call( 'POST', `/api/v1/sessions/${id}/prompts/prompt_does_not_exist:abort`, ); - expect(body.code).toBe(40402); + expect(unknown.body.code).toBe(40402); }); it('returns 40401 for an unknown session', async () => { From f20bb779f99cbb2d55475eabfa8cf524494c066c Mon Sep 17 00:00:00 2001 From: Haozhe Date: Thu, 10 Sep 2026 21:02:25 +0800 Subject: [PATCH 3/8] refactor(agent-core-v2): rename xstate inspection envelope ids to actorId and parentActorId (#3711) --- .../src/human/test/usage/machine.test.ts | 2 +- .../agent-core-v2/src/human/xstateInspection.ts | 13 ++++++++----- packages/kap-server/test/wsUpgradeAuth.test.ts | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/agent-core-v2/src/human/test/usage/machine.test.ts b/packages/agent-core-v2/src/human/test/usage/machine.test.ts index 1f1c1e3cbe1..28c38f98f97 100644 --- a/packages/agent-core-v2/src/human/test/usage/machine.test.ts +++ b/packages/agent-core-v2/src/human/test/usage/machine.test.ts @@ -60,7 +60,7 @@ describe('xstate inspection collector', () => { const delivered = envelopes.filter((envelope) => envelope.eventType === 'usage.record'); expect(delivered.length).toBeGreaterThan(0); for (const envelope of delivered) { - expect(typeof envelope.actorSessionId).toBe('string'); + expect(typeof envelope.actorId).toBe('string'); expect(typeof envelope.timestamp).toBe('number'); } expect(delivered.find((envelope) => envelope.type === '@xstate.microstep')?.stateValue).toBeDefined(); diff --git a/packages/agent-core-v2/src/human/xstateInspection.ts b/packages/agent-core-v2/src/human/xstateInspection.ts index 12671e09e4d..f51ee93fd0d 100644 --- a/packages/agent-core-v2/src/human/xstateInspection.ts +++ b/packages/agent-core-v2/src/human/xstateInspection.ts @@ -5,9 +5,10 @@ export type XstateInspectionEventType = InspectionEvent['type']; export interface XstateInspectionEnvelope { readonly type: XstateInspectionEventType; readonly timestamp: number; - readonly actorSessionId: string; - readonly actorId?: string; + readonly actorId: string; + readonly refId?: string; readonly logicId?: string; + readonly parentActorId?: string; readonly eventType?: string; readonly stateValue?: unknown; } @@ -24,15 +25,17 @@ function scalar(value: unknown): string | undefined { } function toEnvelope(event: InspectionEvent, now: () => number): XstateInspectionEnvelope { - const actorRef = event.actorRef as { id?: unknown; logic?: unknown }; + const actorRef = event.actorRef as { id?: unknown; logic?: unknown; _parent?: unknown }; const logic = actorRef.logic as { id?: unknown } | undefined; + const parent = actorRef._parent as { sessionId?: unknown } | undefined; const snapshot = 'snapshot' in event ? (event.snapshot as { value?: unknown }) : undefined; return { type: event.type, timestamp: now(), - actorSessionId: event.actorRef.sessionId, - actorId: scalar(actorRef.id), + actorId: event.actorRef.sessionId, + refId: scalar(actorRef.id), logicId: scalar(logic?.id), + parentActorId: scalar(parent?.sessionId), eventType: 'event' in event ? event.event.type diff --git a/packages/kap-server/test/wsUpgradeAuth.test.ts b/packages/kap-server/test/wsUpgradeAuth.test.ts index 3cd4c78c2a4..f8980ca2de7 100644 --- a/packages/kap-server/test/wsUpgradeAuth.test.ts +++ b/packages/kap-server/test/wsUpgradeAuth.test.ts @@ -148,7 +148,7 @@ describe('WS upgrade auth', () => { }); expect(envelope['type']).toBe('@xstate.event'); expect(envelope['logicId']).toBe('debugWsProbe'); - expect(typeof envelope['actorSessionId']).toBe('string'); + expect(typeof envelope['actorId']).toBe('string'); expect(typeof envelope['timestamp']).toBe('number'); } finally { await server.close(); From deb11721dff1393cfac8ae8e137bbed1cec68874 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Thu, 10 Sep 2026 21:04:07 +0800 Subject: [PATCH 4/8] feat(kap-server): move compacting from session.state to agent.state turn (#3712) --- .../src/protocol/messages/agent-state.ts | 2 +- .../src/protocol/messages/session-state.ts | 2 +- .../src/services/projection/agentState.ts | 36 +++++++++++++++---- .../services/projection/sessionProjection.ts | 14 ++++++++ .../test/services/projection.test.ts | 13 +++++++ 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/kap-server/src/protocol/messages/agent-state.ts b/packages/kap-server/src/protocol/messages/agent-state.ts index c835001b4a0..0f8c9fde5bd 100644 --- a/packages/kap-server/src/protocol/messages/agent-state.ts +++ b/packages/kap-server/src/protocol/messages/agent-state.ts @@ -25,7 +25,7 @@ export const agentStateOriginSchema = z.discriminatedUnion('kind', [ export type AgentStateOrigin = z.infer; export const agentStateTurnSchema = z.object({ - status: z.enum(['thinking', 'retrying', 'acting', 'aborting']), + status: z.enum(['thinking', 'retrying', 'acting', 'aborting', 'compacting']), }); export type AgentStateTurn = z.infer; diff --git a/packages/kap-server/src/protocol/messages/session-state.ts b/packages/kap-server/src/protocol/messages/session-state.ts index 426b4d4fa30..d28ea744113 100644 --- a/packages/kap-server/src/protocol/messages/session-state.ts +++ b/packages/kap-server/src/protocol/messages/session-state.ts @@ -40,7 +40,7 @@ export type SessionStateModes = z.infer; export const sessionStateMessageSchema = z.object({ type: z.literal('session.state'), ...sessionMessageBase, - status: z.enum(['idle', 'running', 'compacting']), + status: z.enum(['idle', 'running']), pending_interaction: z.enum(['none', 'approval', 'question']).optional(), model: z.string().optional(), thinking_effort: z.string().optional(), diff --git a/packages/kap-server/src/services/projection/agentState.ts b/packages/kap-server/src/services/projection/agentState.ts index 83f4af69330..34c304a388a 100644 --- a/packages/kap-server/src/services/projection/agentState.ts +++ b/packages/kap-server/src/services/projection/agentState.ts @@ -16,6 +16,7 @@ export class AgentStateTracker { private endedAt: string | undefined; private status: AgentStatus = 'idle'; private turn: AgentStateTurn | undefined; + private compacting = false; constructor( readonly agentId: string, @@ -94,6 +95,22 @@ export class AgentStateTracker { if (this.status !== 'running') return false; this.status = 'idle'; this.turn = undefined; + this.compacting = false; + return true; + } + + compactionStarted(): boolean { + if (this.compacting) return false; + this.compacting = true; + if (this.status !== 'running' || this.turn === undefined) return false; + if (this.turn.status === 'compacting') return false; + this.turn = { status: 'compacting' }; + return true; + } + + compactionEnded(): boolean { + if (!this.compacting) return false; + this.compacting = false; return true; } @@ -105,6 +122,7 @@ export class AgentStateTracker { if (this.status === status) return false; this.status = status; this.turn = undefined; + this.compacting = false; this.endedAt = endedAt; return true; } @@ -113,6 +131,7 @@ export class AgentStateTracker { if (this.endedAt !== undefined) return false; this.endedAt = endedAt; this.turn = undefined; + this.compacting = false; if (TERMINAL_STATUSES.has(this.status)) return true; this.status = 'interrupted'; return true; @@ -125,17 +144,20 @@ export class AgentStateTracker { const changed = this.status === 'running' || this.turn !== undefined; if (this.status === 'running') this.status = 'idle'; this.turn = undefined; + this.compacting = false; return changed; } if (this.status === 'idle') this.status = 'running'; const next: AgentStateTurn = { - status: turn.ending - ? 'aborting' - : turn.phase === 'retrying' - ? 'retrying' - : turn.phase === 'tool_call' - ? 'acting' - : 'thinking', + status: this.compacting + ? 'compacting' + : turn.ending + ? 'aborting' + : turn.phase === 'retrying' + ? 'retrying' + : turn.phase === 'tool_call' + ? 'acting' + : 'thinking', }; if (this.turn?.status === next.status) return false; this.turn = next; diff --git a/packages/kap-server/src/services/projection/sessionProjection.ts b/packages/kap-server/src/services/projection/sessionProjection.ts index 96044333266..77689449583 100644 --- a/packages/kap-server/src/services/projection/sessionProjection.ts +++ b/packages/kap-server/src/services/projection/sessionProjection.ts @@ -450,6 +450,20 @@ export class SessionProjection { if (!this.disposed) this.recomputeAgentTurn(agentId); }); return; + case 'compaction.started': { + const tracker = this.agentStates.get(agentId); + if (tracker === undefined) return; + if (tracker.compactionStarted()) this.emitAgentState(agentId); + return; + } + case 'compaction.completed': + case 'compaction.cancelled': { + const tracker = this.agentStates.get(agentId); + if (tracker === undefined) return; + tracker.compactionEnded(); + this.recomputeAgentTurn(agentId); + return; + } default: return; } diff --git a/packages/kap-server/test/services/projection.test.ts b/packages/kap-server/test/services/projection.test.ts index c128bb26260..c29729bc469 100644 --- a/packages/kap-server/test/services/projection.test.ts +++ b/packages/kap-server/test/services/projection.test.ts @@ -1228,6 +1228,14 @@ describe('SessionProjection', () => { }, }; agent.bus.emit(ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_1', name: 'Bash', args: '{}' }) as Event2); + agent.bus.emit(ev({ type: 'compaction.blocked', turnId: 1 }) as Event2); + agent.bus.emit(ev({ type: 'compaction.started', trigger: 'auto' }) as Event2); + agent.bus.emit( + ev({ + type: 'compaction.completed', + result: { summary: 'compacted', compactedCount: 3, tokensBefore: 10, tokensAfter: 5 }, + }) as Event2, + ); agent.activity = {}; agent.bus.emit(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' }) as Event2); agent.bus.emit( @@ -1282,6 +1290,11 @@ describe('SessionProjection', () => { ), ).toBe(true); }); + const mainStates = ofType(received, 'agent.state').filter((m) => m.agent_id === 'main'); + const compactingStates = mainStates.filter((m) => m.turn?.status === 'compacting'); + expect(compactingStates).toHaveLength(1); + const lastActingIndex = mainStates.findLastIndex((m) => m.turn?.status === 'acting'); + expect(lastActingIndex).toBeGreaterThan(mainStates.indexOf(compactingStates[0]!)); const mainIdle = ofType(received, 'agent.state') .filter((m) => m.agent_id === 'main') .at(-1)!; From f5be1c3fd0ee0187fa8b0558a1f9fab0d81bd1da Mon Sep 17 00:00:00 2001 From: Haozhe Date: Thu, 10 Sep 2026 21:37:11 +0800 Subject: [PATCH 5/8] feat(kap-server): open the xstate debug ws by default behind a subscribe handshake (#3713) --- packages/kap-server/src/start.ts | 4 +- .../transport/ws/debug/wsConnectionDebug.ts | 39 ++++++++-- .../kap-server/test/wsUpgradeAuth.test.ts | 76 ++++++++++++------- 3 files changed, 85 insertions(+), 34 deletions(-) diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 5ad117df270..bc8d9f4f8bd 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -482,7 +482,7 @@ export async function startServer(opts: ServerStartOptions): Promise { connectionRegistry.closeAll('server shutting down'); wssV1.close(); - wssDebug?.close(); + wssDebug.close(); wssV3.close(); wsV3Hub.dispose(); await broadcaster.close(); diff --git a/packages/kap-server/src/transport/ws/debug/wsConnectionDebug.ts b/packages/kap-server/src/transport/ws/debug/wsConnectionDebug.ts index 77f6e68248c..c3c8acb9b03 100644 --- a/packages/kap-server/src/transport/ws/debug/wsConnectionDebug.ts +++ b/packages/kap-server/src/transport/ws/debug/wsConnectionDebug.ts @@ -20,12 +20,13 @@ export interface WsConnectionDebugOptions { export class WsConnectionDebug { private readonly socket: WebSocket; + private readonly collector: XstateInspectionCollector; private readonly heartbeatIntervalMs: number; private readonly flushIntervalMs: number; private readonly highWaterMarkBytes: number; - private readonly unsubscribe: () => void; private closed = false; + private collectorUnsubscribe?: () => void; private outbound: XstateInspectionEnvelope[] = []; private flushTimer?: ReturnType; private heartbeatTimer?: ReturnType; @@ -33,6 +34,7 @@ export class WsConnectionDebug { constructor(opts: WsConnectionDebugOptions) { this.socket = opts.socket; + this.collector = opts.collector ?? xstateInspectionCollector; this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; this.flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; this.highWaterMarkBytes = opts.highWaterMarkBytes ?? DEFAULT_HIGH_WATER_MARK_BYTES; @@ -42,14 +44,40 @@ export class WsConnectionDebug { this.socket.on('pong', () => { this.lastPongAt = Date.now(); }); - - const collector = opts.collector ?? xstateInspectionCollector; - this.unsubscribe = collector.subscribe((envelope) => this.onEnvelope(envelope)); + this.socket.on('message', (data) => this.onMessage(data)); this.heartbeatTimer = setInterval(() => this.onHeartbeat(), this.heartbeatIntervalMs); this.heartbeatTimer.unref?.(); } + private onMessage(data: unknown): void { + let frame: unknown; + try { + frame = JSON.parse(String(data)); + } catch { + return; + } + if (frame === null || typeof frame !== 'object') return; + const type = (frame as Record)['type']; + if (type === 'subscribe') { + this.subscribe(); + } else if (type === 'unsubscribe') { + this.unsubscribe(); + } + } + + private subscribe(): void { + if (this.closed || this.collectorUnsubscribe !== undefined) return; + this.collectorUnsubscribe = this.collector.subscribe((envelope) => this.onEnvelope(envelope)); + } + + private unsubscribe(): void { + if (this.collectorUnsubscribe === undefined) return; + this.collectorUnsubscribe(); + this.collectorUnsubscribe = undefined; + this.outbound = []; + } + private onEnvelope(envelope: XstateInspectionEnvelope): void { if (this.closed) return; if (this.socket.bufferedAmount > this.highWaterMarkBytes) return; @@ -109,6 +137,7 @@ export class WsConnectionDebug { if (this.flushTimer !== undefined) clearTimeout(this.flushTimer); if (this.heartbeatTimer !== undefined) clearInterval(this.heartbeatTimer); this.outbound = []; - this.unsubscribe(); + this.collectorUnsubscribe?.(); + this.collectorUnsubscribe = undefined; } } diff --git a/packages/kap-server/test/wsUpgradeAuth.test.ts b/packages/kap-server/test/wsUpgradeAuth.test.ts index f8980ca2de7..211a62089b5 100644 --- a/packages/kap-server/test/wsUpgradeAuth.test.ts +++ b/packages/kap-server/test/wsUpgradeAuth.test.ts @@ -20,6 +20,24 @@ function rawToString(data: RawData): string { return Buffer.from(data as ArrayBuffer).toString('utf8'); } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForFrame( + received: Record[], + match: (frame: Record) => boolean, + timeoutMs = 5000, +): Promise> { + const start = Date.now(); + for (;;) { + const found = received.find(match); + if (found !== undefined) return found; + if (Date.now() - start > timeoutMs) throw new Error('no matching frame within timeout'); + await sleep(20); + } +} + interface ConnectOptions { readonly protocols?: string[]; readonly headers?: Record; @@ -105,7 +123,7 @@ describe('WS upgrade auth', () => { }); describe('/api/v1/debug/ws', () => { - it('streams xstate inspection envelopes to an authorized client', async () => { + it('streams xstate inspection envelopes to an authorized client only after subscribe', async () => { const home = await mkdtemp(join(tmpdir(), 'kimi-kap-debug-ws-')); const server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, @@ -113,7 +131,6 @@ describe('WS upgrade auth', () => { port: 0, homeDir: home, logLevel: 'silent', - debugEndpoints: true, authTokenService: fixedTokenAuth(), seeds: [[IModelCatalog, fakeModelCatalog()]], }); @@ -122,34 +139,41 @@ describe('WS upgrade auth', () => { }); sockets.push(ws); try { - const envelope = await new Promise>((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error('no inspection envelope within timeout')), - 5000, - ); - ws.on('message', (data) => { - const frame = JSON.parse(rawToString(data)) as Record; - if (frame['eventType'] === 'debug.probe') { - clearTimeout(timer); - resolve(frame); - } - }); - ws.on('error', reject); - ws.once('open', () => { - const machine = setup({}).createMachine({ - id: 'debugWsProbe', - initial: 'idle', - states: { idle: { on: { 'debug.probe': 'done' } }, done: {} }, - }); - const actor = createActor(machine); - actor.start(); - actor.send({ type: 'debug.probe' }); - }); + const received: Record[] = []; + ws.on('message', (data) => { + received.push(JSON.parse(rawToString(data)) as Record); }); + await new Promise((resolve) => ws.once('open', resolve)); + const probe = (): void => { + const machine = setup({}).createMachine({ + id: 'debugWsProbe', + initial: 'idle', + states: { idle: { on: { 'debug.probe': 'done' } }, done: {} }, + }); + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'debug.probe' }); + }; + probe(); + await sleep(200); + expect(received).toHaveLength(0); + ws.send(JSON.stringify({ type: 'subscribe' })); + await sleep(100); + probe(); + const envelope = await waitForFrame( + received, + (frame) => frame['eventType'] === 'debug.probe', + ); expect(envelope['type']).toBe('@xstate.event'); expect(envelope['logicId']).toBe('debugWsProbe'); expect(typeof envelope['actorId']).toBe('string'); expect(typeof envelope['timestamp']).toBe('number'); + ws.send(JSON.stringify({ type: 'unsubscribe' })); + await sleep(100); + received.length = 0; + probe(); + await sleep(200); + expect(received).toHaveLength(0); } finally { await server.close(); await rm(home, { recursive: true, force: true }); @@ -160,7 +184,5 @@ describe('WS upgrade auth', () => { it('rejects upgrades to a non-WS path', async () => { const badUrl = `${v1Url().replace('/api/v1/ws', '/api/v1/other')}`; await expectRejected(badUrl, { protocols: [`kimi-code.bearer.${token()}`] }); - const debugUrl = `${v1Url().replace('/api/v1/ws', '/api/v1/debug/ws')}`; - await expectRejected(debugUrl, { protocols: [`kimi-code.bearer.${token()}`] }); }); }); From 6f59ad1f163ca04249224e697a250d8192edd85d Mon Sep 17 00:00:00 2001 From: Haozhe Date: Thu, 10 Sep 2026 22:03:22 +0800 Subject: [PATCH 6/8] fix(kap-server): project swarm member tasks and agent refs in v3 protocol (#3716) --- .../src/services/history/coldFold.ts | 136 ++++++++++++ .../src/services/projection/agentProjector.ts | 5 +- .../services/projection/sessionProjection.ts | 75 +++++++ .../kap-server/test/services/history.test.ts | 71 ++++++- .../test/services/projection.test.ts | 196 +++++++++++++++--- 5 files changed, 446 insertions(+), 37 deletions(-) diff --git a/packages/kap-server/src/services/history/coldFold.ts b/packages/kap-server/src/services/history/coldFold.ts index c639c3d8ee3..be6a9361f90 100644 --- a/packages/kap-server/src/services/history/coldFold.ts +++ b/packages/kap-server/src/services/history/coldFold.ts @@ -1430,6 +1430,105 @@ export function foldWireHistory( }; synthesizeSubagentTasks(); + const synthesizeSwarmMemberTasks = (): void => { + for (const tool of tools.values()) { + if (tool.name !== 'AgentSwarm') continue; + const args = (tool.input ?? {}) as Record; + const resumeIds = + args['resume_agent_ids'] !== null && typeof args['resume_agent_ids'] === 'object' + ? Object.keys(args['resume_agent_ids'] as Record) + : []; + const items = Array.isArray(args['items']) + ? (args['items'] as unknown[]).filter((item): item is string => typeof item === 'string') + : []; + const outputText = typeof tool.output === 'string' ? tool.output : undefined; + const members = outputText === undefined ? [] : parseSwarmMembers(outputText); + for (const agentId of resumeIds) { + if (!tool.agentRefs.some((ref) => ref.agent_id === agentId)) { + tool.agentRefs = [...tool.agentRefs, { agent_id: agentId, role: 'member' }]; + } + } + for (const member of members) { + if ( + member.agentId !== undefined && + !tool.agentRefs.some((ref) => ref.agent_id === member.agentId) + ) { + tool.agentRefs = [...tool.agentRefs, { agent_id: member.agentId, role: 'member' }]; + } + } + const model = typeof args['model'] === 'string' ? args['model'] : undefined; + const thinkingEffort = typeof args['thinking'] === 'string' ? args['thinking'] : undefined; + const swarmDescription = + typeof args['description'] === 'string' ? args['description'] : undefined; + let insertOffset = 1; + const pushMemberTask = ( + agentId: string, + index: number, + member: SwarmMemberResult | undefined, + ): void => { + if (tasks.has(agentId)) return; + const outcome = member?.outcome; + const status = + outcome === 'completed' + ? 'completed' + : outcome === undefined + ? tool.status === 'done' + ? 'completed' + : 'failed' + : 'failed'; + tasks.set(agentId, { + taskId: agentId, + kind: 'subagent', + status, + detached: false, + description: + swarmDescription === undefined ? undefined : `${swarmDescription} #${String(index)}`, + childAgentId: agentId, + outputTail: '', + startedAt: new Date(tool.at).toISOString(), + endedAt: tool.status === 'running' ? undefined : new Date(tool.at).toISOString(), + resultSummary: + outcome === 'completed' && member !== undefined && member.body.length > 0 + ? member.body + : undefined, + error: + outcome !== undefined && outcome !== 'completed' && member !== undefined + ? member.body + : undefined, + stateReason: + member?.stopReason ?? + (outcome === 'aborted' + ? 'aborted' + : status === 'failed' && tool.status === 'running' + ? 'interrupted' + : undefined), + usage: undefined, + model, + thinkingEffort, + at: tool.at, + }); + const toolIndex = order.indexOf(`tool:${tool.toolCallId}`); + if (toolIndex >= 0) order.splice(toolIndex + insertOffset, 0, `task:${agentId}`); + else order.push(`task:${agentId}`); + insertOffset += 1; + }; + for (const [position, agentId] of resumeIds.entries()) { + pushMemberTask( + agentId, + position + 1, + members.find((member) => member.agentId === agentId), + ); + } + for (const member of members) { + if (member.agentId === undefined || resumeIds.includes(member.agentId)) continue; + const itemPosition = + member.item === undefined ? -1 : items.findIndex((item) => item.trim() === member.item); + pushMemberTask(member.agentId, resumeIds.length + itemPosition + 1, member); + } + } + }; + synthesizeSwarmMemberTasks(); + const messages: HistoryMessage[] = []; for (const key of order) { const [kind, id] = splitKey(key); @@ -1610,6 +1709,43 @@ export function foldWireHistory( return messages; } +interface SwarmMemberResult { + readonly agentId?: string; + readonly item?: string; + readonly outcome?: string; + readonly stopReason?: string; + readonly body: string; +} + +function parseSwarmMembers(output: string): SwarmMemberResult[] { + if (!output.includes('')) return []; + const members: SwarmMemberResult[] = []; + for (const match of output.matchAll(/]*)>([\s\S]*?)<\/subagent>/g)) { + const attrs = match[1]!; + const attr = (name: string): string | undefined => { + const value = attrs.match(new RegExp(`${name}="([^"]*)"`))?.[1]; + return value === undefined ? undefined : unescapeXmlAttr(value); + }; + const agentId = attr('agent_id')?.trim(); + members.push({ + agentId: agentId !== undefined && agentId.length > 0 ? agentId : undefined, + item: attr('item'), + outcome: attr('outcome'), + stopReason: attr('stop_reason'), + body: (match[2] ?? '').trim(), + }); + } + return members; +} + +function unescapeXmlAttr(value: string): string { + return value + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); +} + function splitKey(key: string): [string, string] { const index = key.indexOf(':'); return [key.slice(0, index), key.slice(index + 1)]; diff --git a/packages/kap-server/src/services/projection/agentProjector.ts b/packages/kap-server/src/services/projection/agentProjector.ts index 6060f600d9b..9aeaba4ffa4 100644 --- a/packages/kap-server/src/services/projection/agentProjector.ts +++ b/packages/kap-server/src/services/projection/agentProjector.ts @@ -1241,10 +1241,11 @@ export class AgentMessageProjector { tool.agentRefs = [...tool.agentRefs, ref]; ops.push(this.toolOp(tool)); } - const taskId = event.taskId; + const taskId = + event.taskId ?? (event.swarmIndex !== undefined ? event.subagentId : undefined); if (taskId === undefined) return ops; this.subagentTaskIds.set(event.subagentId, taskId); - if (tool !== undefined && tool.taskId !== taskId) { + if (event.taskId !== undefined && tool !== undefined && tool.taskId !== taskId) { tool.taskId = taskId; ops.push(this.toolOp(tool)); } diff --git a/packages/kap-server/src/services/projection/sessionProjection.ts b/packages/kap-server/src/services/projection/sessionProjection.ts index 77689449583..533f0e799ea 100644 --- a/packages/kap-server/src/services/projection/sessionProjection.ts +++ b/packages/kap-server/src/services/projection/sessionProjection.ts @@ -16,6 +16,7 @@ import { INTERACTION_TAG_SESSION_ID, ISessionActivityView, ISessionIndex, + ISessionMetadata, IWireService, MAIN_AGENT_ID, interactions, @@ -575,6 +576,72 @@ export class SessionProjection { if (records === undefined) return; if (this.disposed || this.projectors.get(agentId) !== projector) return; projector.applyTimelineSeed(foldTimelineSeed(records)); + await this.resolveSwarmOriginsFromWire(agentId, records); + } + + private async resolveSwarmOriginsFromWire( + agentId: string, + records: readonly ContextRecord[], + ): Promise { + if (agentId !== MAIN_AGENT_ID) return; + let toolCallId: string | undefined; + let args: Record | undefined; + for (const record of records) { + const event = record['event'] as + | { type?: string; toolCallId?: string; name?: string; args?: unknown } + | undefined; + if (event?.type === 'tool.call' && event.name === 'AgentSwarm') { + if (typeof event.toolCallId !== 'string') continue; + toolCallId = event.toolCallId; + const raw = event.args; + const parsed = typeof raw === 'string' ? safeParseObject(raw) : raw; + args = + parsed !== null && typeof parsed === 'object' + ? (parsed as Record) + : undefined; + } else if (event?.type === 'tool.result' && event.toolCallId === toolCallId) { + toolCallId = undefined; + args = undefined; + } + } + if (toolCallId === undefined || args === undefined) return; + const resumeIds = + args['resume_agent_ids'] !== null && typeof args['resume_agent_ids'] === 'object' + ? Object.keys(args['resume_agent_ids'] as Record) + : []; + const items = Array.isArray(args['items']) + ? (args['items'] as unknown[]).filter((item): item is string => typeof item === 'string') + : []; + const metadata = this.session.accessor.get(ISessionMetadata) as ISessionMetadata | undefined; + const agents = metadata === undefined ? undefined : (await metadata.read()).agents; + if (this.disposed) return; + for (const [memberId, tracker] of this.agentStates) { + if (memberId === MAIN_AGENT_ID || tracker.hasOrigin) continue; + let swarmIndex: number | undefined; + const resumePosition = resumeIds.indexOf(memberId); + if (resumePosition >= 0) { + swarmIndex = resumePosition + 1; + } else { + const meta = agents?.[memberId]; + const item = meta?.labels?.['swarmItem'] ?? meta?.swarmItem; + if (item !== undefined) { + const itemPosition = items.findIndex((candidate) => candidate.trim() === item); + if (itemPosition >= 0) swarmIndex = resumeIds.length + itemPosition + 1; + } + } + if (swarmIndex === undefined) continue; + const profile = this.agentHandle(memberId)?.accessor.get(IAgentProfileService) as + | IAgentProfileService + | undefined; + const seeded = tracker.seedToolSpawned({ + subagentId: memberId, + subagentName: profile?.data().profileName ?? '', + parentToolCallId: toolCallId, + parentAgentId: 'main', + swarmIndex, + }); + if (seeded) this.emitAgentState(memberId); + } } private async healTurns(agentId: string, ordinals: ReadonlySet): Promise { @@ -700,3 +767,11 @@ function interactionAgentId(interaction: Interaction): string { MAIN_AGENT_ID ); } + +function safeParseObject(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} diff --git a/packages/kap-server/test/services/history.test.ts b/packages/kap-server/test/services/history.test.ts index dee4e6bd967..0a2ea582947 100644 --- a/packages/kap-server/test/services/history.test.ts +++ b/packages/kap-server/test/services/history.test.ts @@ -731,7 +731,7 @@ describe('foldWireHistory interactions, facts and modes', () => { }); }); - it('links subagent tasks to their parent tool call with agent refs', () => { + it('links subagent and swarm member tasks to their parent tool call with agent refs', () => { const messages = fold([ rec('turn.prompt', { input: [{ type: 'text', text: 'go' }], origin: { kind: 'user' } }), loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), @@ -768,6 +768,75 @@ describe('foldWireHistory interactions, facts and modes', () => { task_id: 'task-2', agent_refs: [{ agent_id: 'sub-1', role: 'child' }], }); + + const swarmOutput = [ + '', + 'completed: 2, failed: 1', + 'resume report', + 'alpha report', + 'beta blew up', + '', + ].join('\n'); + const swarmMessages = fold([ + rec('turn.prompt', { input: [{ type: 'text', text: 'go' }], origin: { kind: 'user' } }), + loopEvent({ type: 'step.begin', uuid: 'u1', turnId: '0', step: 1 }, T0 + 1), + loopEvent( + { + type: 'tool.call', + stepUuid: 'u1', + toolCallId: 'call_s', + name: 'AgentSwarm', + args: JSON.stringify({ + description: 'team', + items: ['alpha', 'beta'], + prompt_template: 'do {{item}}', + model: 'k2', + thinking: 'high', + resume_agent_ids: { 'agent-9': 'resume work' }, + }), + }, + T0 + 2, + ), + loopEvent( + { + type: 'tool.result', + stepUuid: 'u1', + toolCallId: 'call_s', + result: { output: swarmOutput }, + }, + T0 + 3, + ), + ]); + const swarmTool = ofType(swarmMessages, 'tool_call')[0]!; + expect(swarmTool.task_id).toBeUndefined(); + expect(swarmTool.agent_refs).toEqual([ + { agent_id: 'agent-9', role: 'member' }, + { agent_id: 'agent-11', role: 'member' }, + { agent_id: 'agent-12', role: 'member' }, + ]); + const memberTasks = ofType(swarmMessages, 'task'); + expect(memberTasks.map((t) => t.task_id)).toEqual(['agent-9', 'agent-11', 'agent-12']); + expect(memberTasks[0]).toMatchObject({ + kind: 'subagent', + status: 'completed', + detached: false, + child_agent_id: 'agent-9', + description: 'team #1', + result_summary: 'resume report', + model: 'k2', + thinking_effort: 'high', + }); + expect(memberTasks[1]).toMatchObject({ + status: 'completed', + description: 'team #2', + result_summary: 'alpha report', + }); + expect(memberTasks[2]).toMatchObject({ + status: 'failed', + description: 'team #3', + error: 'beta blew up', + state_reason: 'rate_limit', + }); }); }); diff --git a/packages/kap-server/test/services/projection.test.ts b/packages/kap-server/test/services/projection.test.ts index c29729bc469..8f6031547d3 100644 --- a/packages/kap-server/test/services/projection.test.ts +++ b/packages/kap-server/test/services/projection.test.ts @@ -1,3 +1,7 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { IAgentGoalService, IAgentLifecycleService, @@ -11,6 +15,7 @@ import { IAgentTodoService, IEventBus, ISessionActivityView, + ISessionIndex, ISessionTokenCountingService, ISessionUsageService, interactions, @@ -628,6 +633,78 @@ describe('AgentMessageProjector', () => { }); const toolC = ofType(sink, 'tool_call').at(-1)!; expect(toolC.task_id).toBe('task-c'); + + feed( + projector, + ev({ + type: 'tool.call.started', + turnId: 1, + toolCallId: 'call_d', + name: 'AgentSwarm', + args: '{"items":["a","b"],"prompt_template":"do {{item}}"}', + }), + sink, + ); + feed( + projector, + ev({ + type: 'subagent.spawned', + subagentId: 'sub-d1', + parentToolCallId: 'call_d', + swarmIndex: 1, + runInBackground: false, + description: 'team #1 (coder)', + model: 'k2', + thinkingEffort: 'high', + }), + sink, + ); + feed( + projector, + ev({ + type: 'subagent.spawned', + subagentId: 'sub-d2', + parentToolCallId: 'call_d', + swarmIndex: 2, + runInBackground: false, + }), + sink, + ); + const toolD = ofType(sink, 'tool_call').at(-1)!; + expect(toolD.agent_refs).toEqual([ + { agent_id: 'sub-d1', role: 'member' }, + { agent_id: 'sub-d2', role: 'member' }, + ]); + expect(toolD.task_id).toBeUndefined(); + const memberTasks = ofType(sink, 'task').filter((t) => t.task_id.startsWith('sub-d')); + expect(memberTasks).toHaveLength(2); + expect(memberTasks[0]).toMatchObject({ + task_id: 'sub-d1', + kind: 'subagent', + status: 'running', + detached: false, + child_agent_id: 'sub-d1', + description: 'team #1 (coder)', + model: 'k2', + thinking_effort: 'high', + }); + expect(memberTasks[1]).toMatchObject({ task_id: 'sub-d2', child_agent_id: 'sub-d2' }); + feed( + projector, + ev({ + type: 'subagent.completed', + subagentId: 'sub-d1', + resultSummary: 'member report', + usage: { inputOther: 3, output: 1, inputCacheRead: 0, inputCacheCreation: 0 }, + }), + sink, + ); + expect(ofType(sink, 'task').at(-1)).toMatchObject({ + task_id: 'sub-d1', + status: 'completed', + result_summary: 'member report', + usage: { input_other: 3, output: 1, input_cache_read: 0, input_cache_creation: 0 }, + }); }); it('drives todo entities from the todo emitter and links TodoList tool calls', () => { @@ -686,7 +763,7 @@ describe('AgentMessageProjector', () => { expect(projector.healTurn(1, fold).length).toBeGreaterThan(0); }); - it('settles a full-cut splice as system(clear) unless a context.undone follows', () => { + it('settles a full-cut splice as system(clear) on new events, on timeout, or as undo', () => { const projector = makeProjector(); const before = feedAll(projector, [ ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'one' }), @@ -711,6 +788,30 @@ describe('AgentMessageProjector', () => { ]); expect(ofType(undoOnly, 'system').some((m) => m.subtype === 'clear')).toBe(false); expect(ofType(undoOnly, 'system').some((m) => m.subtype === 'undo')).toBe(true); + + vi.useFakeTimers(); + try { + const deferred: ServerMessage[] = []; + const projector3 = new AgentMessageProjector('main', SESSION, new Map(), undefined, { + onDeferred: (messages) => deferred.push(...messages), + }); + const pending = feedAll(projector3, [ + ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'one' }), + ev({ type: 'turn.ended', turnId: 1, reason: 'completed' }), + ev({ type: 'context.spliced', start: 0, deleteCount: 4, messages: [] }), + ]); + expect(ofType(pending, 'system')).toHaveLength(0); + expect(deferred).toHaveLength(0); + vi.advanceTimersByTime(150); + const timedClear = ofType(deferred, 'system').find((m) => m.subtype === 'clear'); + expect(serverMessageSchema.parse(timedClear)).toMatchObject({ + subtype: 'clear', + payload: { removed_ids: ['t1'] }, + }); + projector3.dispose(); + } finally { + vi.useRealTimers(); + } }); it('replays in-flight entities plus state entities as recovery payload', () => { @@ -871,32 +972,6 @@ describe('AgentMessageProjector', () => { expect(users[0]).toMatchObject({ message_id: 't1.u0', text: [{ type: 'text', text: 'hello', meta: {} }] }); }); - it('settles a full-cut splice as system(clear) after a bounded wait when no undo follows', () => { - vi.useFakeTimers(); - try { - const deferred: ServerMessage[] = []; - const projector = new AgentMessageProjector('main', SESSION, new Map(), undefined, { - onDeferred: (messages) => deferred.push(...messages), - }); - const before = feedAll(projector, [ - ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'one' }), - ev({ type: 'turn.ended', turnId: 1, reason: 'completed' }), - ev({ type: 'context.spliced', start: 0, deleteCount: 4, messages: [] }), - ]); - expect(ofType(before, 'system')).toHaveLength(0); - expect(deferred).toHaveLength(0); - vi.advanceTimersByTime(150); - const clear = ofType(deferred, 'system').find((m) => m.subtype === 'clear'); - expect(serverMessageSchema.parse(clear)).toMatchObject({ - subtype: 'clear', - payload: { removed_ids: ['t1'] }, - }); - projector.dispose(); - } finally { - vi.useRealTimers(); - } - }); - it('counts undo anchors instead of timeline turns when fromTurnId is missing', () => { const projector = makeProjector(); const messages = feedAll(projector, [ @@ -1124,7 +1199,10 @@ describe('SessionProjection', () => { return agent; } - function makeSession(...agents: FakeAgent[]): { + function makeSession( + agents: readonly FakeAgent[], + opts?: { sessionIndex?: unknown }, + ): { session: ISessionScopeHandle; core: Scope; activityEmitter: Emitter<{ state: { busy: boolean; mainTurnActive: boolean; pendingInteraction: 'none' | 'approval' | 'question' }; cause: string }>; @@ -1166,6 +1244,7 @@ describe('SessionProjection', () => { if (token === IAgentLifecycleService) { throw new Error('strict DI: IAgentLifecycleService is not registered at app scope'); } + if (token === ISessionIndex) return opts?.sessionIndex; return undefined; }, }, @@ -1173,17 +1252,20 @@ describe('SessionProjection', () => { return { session, core, activityEmitter }; } - function makeProjection(...agents: FakeAgent[]): { + function makeProjection( + agents: readonly FakeAgent[], + opts?: { homeDir?: string; sessionIndex?: unknown }, + ): { projection: SessionProjection; received: ServerMessage[]; logger: { warn: ReturnType }; activityEmitter: Emitter<{ state: { busy: boolean; mainTurnActive: boolean; pendingInteraction: 'none' | 'approval' | 'question' }; cause: string }>; } { - const { session, core, activityEmitter } = makeSession(...agents); + const { session, core, activityEmitter } = makeSession(agents, opts); const received: ServerMessage[] = []; const logger = { warn: vi.fn() }; const projection = new SessionProjection(SESSION, session, { - homeDir: '/nonexistent', + homeDir: opts?.homeDir ?? '/nonexistent', core, logger, }); @@ -1195,7 +1277,7 @@ describe('SessionProjection', () => { const agent = makeAgent('main'); const child = makeAgent('agent-1'); const swarmChild = makeAgent('agent-2'); - const { projection, received, logger } = makeProjection(agent, child, swarmChild); + const { projection, received, logger } = makeProjection([agent, child, swarmChild]); const bindMainState = ofType(projection.recoveryMessages(), 'agent.state').find( (m) => m.agent_id === 'main', )!; @@ -1348,7 +1430,7 @@ describe('SessionProjection', () => { it('emits the interaction lifecycle and drops outbound messages that fail schema validation', () => { const agent = makeAgent('main'); - const { projection, received, logger } = makeProjection(agent); + const { projection, received, logger } = makeProjection([agent]); interactions.enqueue({ id: 'q-1', kind: 'question', @@ -1388,7 +1470,7 @@ describe('SessionProjection', () => { const agent = makeAgent('main'); agent.planActive = true; agent.swarmTrigger = 'tool'; - const { projection, received } = makeProjection(agent); + const { projection, received } = makeProjection([agent]); const recovery = projection.recoveryMessages(); const state = ofType(recovery, 'session.state')[0]!; expect(state.modes).toEqual({ plan: {}, swarm: {} }); @@ -1431,4 +1513,50 @@ describe('SessionProjection', () => { expect(ofType(received, 'system').map((m) => m.subtype)).toContain('plan.exit'); projection.dispose(); }); + + it('seeds tool-swarm origins for pre-existing members from the wire at bind', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'projection-swarm-bind-')); + try { + const wireDir = join(homeDir, 'sessions', 'ws1', SESSION, 'agents', 'main'); + await mkdir(wireDir, { recursive: true }); + await writeFile( + join(wireDir, 'wire.jsonl'), + `${JSON.stringify({ + type: 'loop.event', + time: T0, + event: { + type: 'tool.call', + stepUuid: 'u1', + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: JSON.stringify({ resume_agent_ids: { 'agent-2': 'continue the fix' } }), + }, + })}\n`, + ); + const agent = makeAgent('main'); + const member = makeAgent('agent-2'); + const { projection, received } = makeProjection([agent, member], { + homeDir, + sessionIndex: { get: async () => ({ workspaceId: 'ws1' }) }, + }); + expect(ofType(received, 'agent.state').some((m) => m.agent_id === 'agent-2')).toBe(false); + await vi.waitFor(() => { + const state = ofType(projection.recoveryMessages(), 'agent.state').find( + (m) => m.agent_id === 'agent-2', + ); + expect(state).toMatchObject({ + profile: { kind: 'coder' }, + origin: { + kind: 'tool-swarm', + tool_call_id: 'call_swarm', + swarm_index: 1, + parent_agent_id: 'main', + }, + }); + }); + projection.dispose(); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }); }); From fde8ccef73946853d75676b096297160c6832677 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Thu, 10 Sep 2026 22:26:12 +0800 Subject: [PATCH 7/8] refactor(kap-server): drop the legacy v1 and transcript protocol surfaces (#3719) --- AGENTS.md | 5 +- apps/kimi-inspect/AGENTS.md | 4 +- apps/kimi-inspect/README.md | 8 +- apps/kimi-inspect/src/activity/di.ts | 53 - apps/kimi-inspect/src/activity/store.test.ts | 122 +- apps/kimi-inspect/src/activity/store.ts | 67 +- apps/kimi-inspect/src/activity/ws.ts | 235 +- apps/kimi-inspect/src/channel/client.ts | 4 +- apps/kimi-inspect/src/channel/wsLike.ts | 7 +- .../src/components/DiInspectionView.tsx | 8 +- docs/en/guides/web.md | 2 +- docs/en/reference/kimi-command.md | 2 +- docs/en/reference/server-api.md | 258 +- docs/zh/guides/web.md | 2 +- docs/zh/reference/kimi-command.md | 2 +- docs/zh/reference/server-api.md | 258 +- flake.nix | 2 - .../agent-core-v2/src/human/agent/turn.ts | 1 - packages/kap-server/package.json | 1 - packages/kap-server/src/middleware/auth.ts | 2 +- packages/kap-server/src/protocol/asyncapi.ts | 144 - .../kap-server/src/protocol/events-zod.ts | 1112 ----- .../src/protocol/messages/session.ts | 1 - .../kap-server/src/protocol/rest-message.ts | 21 - .../kap-server/src/protocol/rest-snapshot.ts | 58 - packages/kap-server/src/protocol/session.ts | 1 - .../kap-server/src/protocol/ws-control.ts | 570 --- packages/kap-server/src/routes/messages.ts | 138 - .../src/routes/registerApiV1Routes.ts | 20 - packages/kap-server/src/routes/sessions.ts | 13 +- packages/kap-server/src/routes/snapshot.ts | 188 - packages/kap-server/src/routes/transcript.ts | 584 --- packages/kap-server/src/search/indexCore.ts | 110 +- packages/kap-server/src/search/liveSource.ts | 140 + .../kap-server/src/search/searchService.ts | 101 +- packages/kap-server/src/search/wireExtract.ts | 104 + .../services/legacyStatus/legacyActivity.ts | 146 - .../src/services/legacyStatus/legacyStatus.ts | 171 - .../src/services/messages/messageHistory.ts | 183 - .../src/services/transcript/coreBinding.ts | 306 -- .../src/services/transcript/coreEventMap.ts | 1701 ------- .../src/services/transcript/index.ts | 3 - .../services/transcript/transcriptService.ts | 754 ---- .../src/services/transcript/wireRecords.ts | 27 - packages/kap-server/src/start.ts | 40 +- .../kap-server/src/transport/ws/v1/events.ts | 263 -- .../transport/ws/v1/inFlightTurnTracker.ts | 127 - .../src/transport/ws/v1/protocol.ts | 73 - .../src/transport/ws/v1/registerWsV1.ts | 49 - .../ws/v1/sessionEventBroadcaster.ts | 1561 ------- .../transport/ws/v1/sessionEventJournal.ts | 225 - .../transport/ws/v1/subagentRosterTracker.ts | 108 - .../src/transport/ws/v1/wsConnectionV1.ts | 570 --- .../src/transport/ws/v3/globalTranslator.ts | 28 + .../src/transport/ws/v3/registerWsV3.ts | 39 + .../src/transport/ws/v3/wsV3Deps.ts | 1 + .../apiSurface.snapshot.test.ts.snap | 37 - .../test/apiSurface.snapshot.test.ts | 2 +- .../kap-server/test/authWiring.e2e.test.ts | 20 +- packages/kap-server/test/config.test.ts | 29 +- packages/kap-server/test/connections.test.ts | 21 +- .../kap-server/test/disableAuth.e2e.test.ts | 6 +- .../test/inFlightTurnTracker.test.ts | 122 - .../kap-server/test/mediaRefParity.test.ts | 77 - packages/kap-server/test/messages.test.ts | 340 -- packages/kap-server/test/plugins.test.ts | 8 +- .../kap-server/test/protocolMessages.test.ts | 1 - .../test/search/searchRoute.test.ts | 4 +- .../test/search/searchService.test.ts | 234 +- .../test/services/transcript.test.ts | 3988 ----------------- .../test/sessionEventBroadcaster.test.ts | 3074 ------------- .../test/sessionEventJournal.test.ts | 114 - packages/kap-server/test/sessions.test.ts | 68 +- packages/kap-server/test/skills.test.ts | 63 +- packages/kap-server/test/snapshot.test.ts | 578 --- .../test/subagentRosterTracker.test.ts | 177 - packages/kap-server/test/transcript.test.ts | 1437 ------ .../test/transcriptContract.e2e.test.ts | 583 --- .../kap-server/test/workspaceLayout.test.ts | 10 +- .../kap-server/test/wsBearerProtocol.test.ts | 4 +- .../kap-server/test/wsConnectionV1.test.ts | 902 ---- packages/kap-server/test/wsHostOrigin.test.ts | 10 +- .../kap-server/test/wsUpgradeAuth.test.ts | 14 +- packages/kap-server/test/wsV1Resync.test.ts | 285 -- packages/kap-server/test/wsV3.test.ts | 71 +- packages/klient/AGENTS.md | 27 +- packages/klient/Dockerfile | 27 - packages/klient/README.md | 11 - packages/klient/package.json | 9 +- packages/klient/scripts/run-docker-e2e.sh | 185 - packages/klient/test/e2e/harness/client.ts | 706 --- packages/klient/test/e2e/harness/envelope.ts | 52 - packages/klient/test/e2e/harness/http.ts | 501 --- packages/klient/test/e2e/harness/index.ts | 70 - packages/klient/test/e2e/harness/report.ts | 662 --- .../klient/test/e2e/harness/reverse-rpc.ts | 66 - packages/klient/test/e2e/harness/wait.ts | 56 - packages/klient/test/e2e/harness/ws.ts | 275 -- .../klient/test/e2e/legacy/client.test.ts | 726 --- .../e2e/legacy/image-file-prompts.test.ts | 147 - packages/klient/test/e2e/legacy/log.ts | 59 - .../e2e/legacy/prompt-queue-steer.test.ts | 197 - .../test/e2e/legacy/refresh-replay.test.ts | 363 -- .../klient/test/e2e/legacy/report.test.ts | 482 -- .../test/e2e/legacy/report/vitest-reporter.ts | 43 - .../test/e2e/legacy/send-and-cancel.test.ts | 406 -- .../test/e2e/legacy/session-resume.test.ts | 207 - .../klient/test/e2e/legacy/terminal.test.ts | 172 - packages/klient/tsconfig.json | 2 +- packages/klient/vitest.config.ts | 1 - packages/migration-legacy/package.json | 3 - .../src/sessions/turn-structure.ts | 12 +- .../src/sessions/wire-writer.ts | 2 +- .../test/resume.integration.test.ts | 23 +- .../test/remote-control.test.ts | 2 +- packages/transcript/CHANGELOG.md | 13 - packages/transcript/package.json | 35 - packages/transcript/src/contract/events.ts | 37 - packages/transcript/src/contract/mediaRef.ts | 60 - packages/transcript/src/contract/origin.ts | 20 - packages/transcript/src/contract/schema.ts | 557 --- .../transcript/src/granularity/filterOps.ts | 39 - packages/transcript/src/granularity/grade.ts | 34 - packages/transcript/src/history/foldFacts.ts | 758 ---- packages/transcript/src/history/groupTurns.ts | 581 --- packages/transcript/src/index.ts | 25 - packages/transcript/src/model/attachment.ts | 15 - packages/transcript/src/model/frame.ts | 94 - packages/transcript/src/model/ids.ts | 33 - packages/transcript/src/model/interaction.ts | 20 - packages/transcript/src/model/item.ts | 48 - packages/transcript/src/model/meta.ts | 109 - packages/transcript/src/model/prompt.ts | 19 - packages/transcript/src/model/task.ts | 30 - packages/transcript/src/model/todo.ts | 14 - packages/transcript/src/model/turn.ts | 83 - packages/transcript/src/ops/apply.ts | 586 --- packages/transcript/src/ops/operation.ts | 143 - .../transcript/src/pagination/paginate.ts | 76 - .../transcript/src/store/agentTranscript.ts | 162 - .../transcript/src/store/transcriptStore.ts | 71 - packages/transcript/src/view/registry.ts | 61 - packages/transcript/test/layers.test.ts | 2340 ---------- packages/transcript/test/store.test.ts | 587 --- packages/transcript/tsconfig.json | 7 - packages/transcript/tsdown.config.ts | 13 - packages/transcript/vitest.config.ts | 8 - pnpm-lock.yaml | 26 - scripts/check-no-comments.mjs | 2 +- 149 files changed, 889 insertions(+), 34313 deletions(-) delete mode 100644 apps/kimi-inspect/src/activity/di.ts delete mode 100644 packages/kap-server/src/protocol/asyncapi.ts delete mode 100644 packages/kap-server/src/protocol/events-zod.ts delete mode 100644 packages/kap-server/src/protocol/rest-message.ts delete mode 100644 packages/kap-server/src/protocol/rest-snapshot.ts delete mode 100644 packages/kap-server/src/protocol/ws-control.ts delete mode 100644 packages/kap-server/src/routes/messages.ts delete mode 100644 packages/kap-server/src/routes/snapshot.ts delete mode 100644 packages/kap-server/src/routes/transcript.ts create mode 100644 packages/kap-server/src/search/liveSource.ts delete mode 100644 packages/kap-server/src/services/legacyStatus/legacyActivity.ts delete mode 100644 packages/kap-server/src/services/messages/messageHistory.ts delete mode 100644 packages/kap-server/src/services/transcript/coreBinding.ts delete mode 100644 packages/kap-server/src/services/transcript/coreEventMap.ts delete mode 100644 packages/kap-server/src/services/transcript/index.ts delete mode 100644 packages/kap-server/src/services/transcript/transcriptService.ts delete mode 100644 packages/kap-server/src/services/transcript/wireRecords.ts delete mode 100644 packages/kap-server/src/transport/ws/v1/events.ts delete mode 100644 packages/kap-server/src/transport/ws/v1/inFlightTurnTracker.ts delete mode 100644 packages/kap-server/src/transport/ws/v1/protocol.ts delete mode 100644 packages/kap-server/src/transport/ws/v1/registerWsV1.ts delete mode 100644 packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts delete mode 100644 packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts delete mode 100644 packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts delete mode 100644 packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts delete mode 100644 packages/kap-server/test/inFlightTurnTracker.test.ts delete mode 100644 packages/kap-server/test/mediaRefParity.test.ts delete mode 100644 packages/kap-server/test/messages.test.ts delete mode 100644 packages/kap-server/test/services/transcript.test.ts delete mode 100644 packages/kap-server/test/sessionEventBroadcaster.test.ts delete mode 100644 packages/kap-server/test/sessionEventJournal.test.ts delete mode 100644 packages/kap-server/test/snapshot.test.ts delete mode 100644 packages/kap-server/test/subagentRosterTracker.test.ts delete mode 100644 packages/kap-server/test/transcript.test.ts delete mode 100644 packages/kap-server/test/transcriptContract.e2e.test.ts delete mode 100644 packages/kap-server/test/wsConnectionV1.test.ts delete mode 100644 packages/kap-server/test/wsV1Resync.test.ts delete mode 100644 packages/klient/Dockerfile delete mode 100755 packages/klient/scripts/run-docker-e2e.sh delete mode 100644 packages/klient/test/e2e/harness/client.ts delete mode 100644 packages/klient/test/e2e/harness/envelope.ts delete mode 100644 packages/klient/test/e2e/harness/http.ts delete mode 100644 packages/klient/test/e2e/harness/index.ts delete mode 100644 packages/klient/test/e2e/harness/report.ts delete mode 100644 packages/klient/test/e2e/harness/reverse-rpc.ts delete mode 100644 packages/klient/test/e2e/harness/wait.ts delete mode 100644 packages/klient/test/e2e/harness/ws.ts delete mode 100644 packages/klient/test/e2e/legacy/client.test.ts delete mode 100644 packages/klient/test/e2e/legacy/image-file-prompts.test.ts delete mode 100644 packages/klient/test/e2e/legacy/log.ts delete mode 100644 packages/klient/test/e2e/legacy/prompt-queue-steer.test.ts delete mode 100644 packages/klient/test/e2e/legacy/refresh-replay.test.ts delete mode 100644 packages/klient/test/e2e/legacy/report.test.ts delete mode 100644 packages/klient/test/e2e/legacy/report/vitest-reporter.ts delete mode 100644 packages/klient/test/e2e/legacy/send-and-cancel.test.ts delete mode 100644 packages/klient/test/e2e/legacy/session-resume.test.ts delete mode 100644 packages/klient/test/e2e/legacy/terminal.test.ts delete mode 100644 packages/transcript/CHANGELOG.md delete mode 100644 packages/transcript/package.json delete mode 100644 packages/transcript/src/contract/events.ts delete mode 100644 packages/transcript/src/contract/mediaRef.ts delete mode 100644 packages/transcript/src/contract/origin.ts delete mode 100644 packages/transcript/src/contract/schema.ts delete mode 100644 packages/transcript/src/granularity/filterOps.ts delete mode 100644 packages/transcript/src/granularity/grade.ts delete mode 100644 packages/transcript/src/history/foldFacts.ts delete mode 100644 packages/transcript/src/history/groupTurns.ts delete mode 100644 packages/transcript/src/index.ts delete mode 100644 packages/transcript/src/model/attachment.ts delete mode 100644 packages/transcript/src/model/frame.ts delete mode 100644 packages/transcript/src/model/ids.ts delete mode 100644 packages/transcript/src/model/interaction.ts delete mode 100644 packages/transcript/src/model/item.ts delete mode 100644 packages/transcript/src/model/meta.ts delete mode 100644 packages/transcript/src/model/prompt.ts delete mode 100644 packages/transcript/src/model/task.ts delete mode 100644 packages/transcript/src/model/todo.ts delete mode 100644 packages/transcript/src/model/turn.ts delete mode 100644 packages/transcript/src/ops/apply.ts delete mode 100644 packages/transcript/src/ops/operation.ts delete mode 100644 packages/transcript/src/pagination/paginate.ts delete mode 100644 packages/transcript/src/store/agentTranscript.ts delete mode 100644 packages/transcript/src/store/transcriptStore.ts delete mode 100644 packages/transcript/src/view/registry.ts delete mode 100644 packages/transcript/test/layers.test.ts delete mode 100644 packages/transcript/test/store.test.ts delete mode 100644 packages/transcript/tsconfig.json delete mode 100644 packages/transcript/tsdown.config.ts delete mode 100644 packages/transcript/vitest.config.ts diff --git a/AGENTS.md b/AGENTS.md index ca4068bbc1e..eae46a7dca4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `packages/kaos`: the execution environment and file/process abstractions. - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. -- `packages/transcript`: the isomorphic transcript rendering data layer — L1 agent-granular store, L2 idempotent operations, L3 `off/turn/block/delta` subscription granularity, L4 framework-free view registry, plus turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports); the sole owner of the transcript contract types (`src/contract/`) and the op-batch sequencing contract. -- `packages/kap-server`: the Kimi Code server, backed by `@moonshot-ai/agent-core-v2`; exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`), plus the `/api/v1/debug/*` reflection RPC surface (`--debug-endpoints`, loopback bind + bearer auth). +- `packages/kap-server`: the Kimi Code server, backed by `@moonshot-ai/agent-core-v2`; exposes sessions over REST (`/api/v1`) and the v3 flat entity message WebSocket protocol (`/api/v3/ws`), plus the `/api/v1/debug/*` reflection RPC surface (`--debug-endpoints`, loopback bind + bearer auth). - `packages/remote-control`: the Kimi Remote Control tunnel client — registers this machine with the relay and forwards HTTP/WebSocket traffic to the local server, with a machine-wide single-instance lock; consumed by kap-server (the `/api/v1/remote-control` toggle) and by the CLI (`kimi web --remote-control`). - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 (`global.*` / `session(id).*` / `agent(id).*`, zod-validated); transport via subpath entry (`@moonshot-ai/klient/ipc|memory`, both return the same `Klient`); also hosts the e2e suites. See `packages/klient/AGENTS.md`. - `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm); `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget and returns a discriminated `ParseResult` — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; see the package README's "Known differences" section. @@ -48,7 +47,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo ## General Coding Rules -- `packages/agent-core-v2`, `packages/kap-server`, and `packages/transcript` are comment-free zones: no comments of any kind — no line/block comments, no JSDoc (not even on exported symbols); the only exception is load-bearing lint-suppression directives (`oxlint-disable` / `eslint-disable`), while other tooling directives (`@ts-expect-error`, …) stay banned. Enforced by `scripts/check-no-comments.mjs` over `.ts`/`.tsx`/`.mts`/`.mjs` under `src/`/`test/`/`scripts/`, which runs as part of `pnpm lint`. +- `packages/agent-core-v2` and `packages/kap-server` are comment-free zones: no comments of any kind — no line/block comments, no JSDoc (not even on exported symbols); the only exception is load-bearing lint-suppression directives (`oxlint-disable` / `eslint-disable`), while other tooling directives (`@ts-expect-error`, …) stay banned. Enforced by `scripts/check-no-comments.mjs` over `.ts`/`.tsx`/`.mts`/`.mjs` under `src/`/`test/`/`scripts/`, which runs as part of `pnpm lint`. - For optional object properties, pass `undefined` directly instead of using conditional spread. - YES: `{ user }` - NO: `{ ...(user ? { user } : undefined) }` diff --git a/apps/kimi-inspect/AGENTS.md b/apps/kimi-inspect/AGENTS.md index 27698c97833..6d77a1fd83f 100644 --- a/apps/kimi-inspect/AGENTS.md +++ b/apps/kimi-inspect/AGENTS.md @@ -10,7 +10,7 @@ A left icon rail (`src/components/NavRail.tsx`) switches top-level views: - **Global message search** (`src/components/SearchView.tsx`) — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index). - **Model Catalog** (`src/components/ModelCatalogView.tsx`) — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies, with per-model ping and session creation actions. - **App Services** (`src/components/AppServicesView.tsx`) — the app-scope Service reflection, full width, joined by the **Workspace Services** view (`src/components/WorkspaceServicesView.tsx`) — the workspace-scope counterpart with a left sidebar directory browser (`src/components/WorkspaceDirBrowser.tsx` — server-side fs browsing over the App-scope `IHostFolderBrowser`, marking entries that are registered workspaces with their `IWorkspaceTrust` trust state, and registering a picked folder on demand via `IWorkspaceService.createOrTouch`), its proxies riding the `/workspace/:id` route, which materializes the handler on demand via `IWorkspaceLifecycleService.handlerFor`. -- **DI view** (`src/components/DiInspectionView.tsx`) — the engine's Service × Effect × DI debug surface over the App-scope `IDebugLedgerService` / `IDebugGraphService` / `IDebugEventsService` / `IDebugCascadeService`: the unit tree = ledger tree with unprovide / update / dispose triggers, the dependency DAG as Miller columns (`di/DiGraphPanel.tsx`), the event-subscription ledger (unit-book `on:` entries + per-bus listener counts, `di/DiEventsPanel.tsx`), the cascade history, and the waiting area; the five panels poll on a short interval and refresh eagerly off the global `event.di.unit_changed` WS frame via `src/activity/di.ts`, which invalidates the `['di']` react-query prefix. +- **DI view** (`src/components/DiInspectionView.tsx`) — the engine's Service × Effect × DI debug surface over the App-scope `IDebugLedgerService` / `IDebugGraphService` / `IDebugEventsService` / `IDebugCascadeService`: the unit tree = ledger tree with unprovide / update / dispose triggers, the dependency DAG as Miller columns (`di/DiGraphPanel.tsx`), the event-subscription ledger (unit-book `on:` entries + per-bus listener counts, `di/DiEventsPanel.tsx`), the cascade history, and the waiting area; the five panels poll on a short interval over the `/api/v1/debug` RPC (no push channel — a settled trigger invalidates the `['di']` react-query prefix so every panel converges). The **Agent scope** stays in the Chat view's right dock (`src/components/RightPanel.tsx` — Audit / Agent / State / Session tabs) across two of them: @@ -25,7 +25,7 @@ Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `Pr ## Session activity -Session-level coarse status is the one exception to no-push: `src/activity/` holds a second `/api/v1/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global facts — `event.session.work_changed` updates a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while `event.session.created` / `event.session.archived` / `session.meta.updated` / `event.workspace.*` invalidate the `['sessions']` / `['v2-sessions']` / `['workspaces']` queries (an archive also drops the session's live activity entry, since no further `work_changed` frames will correct a stale badge); the session tree rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities` (live facts override the REST `activity.status`). +Session-level coarse status is the one exception to no-push: `src/activity/` holds a second `/api/v3/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global messages — the hub fans them out to every established connection with no subscribe frame — where every `session` message (created / updated / archived / deleted) embeds the full SessionInfo whose `busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason` update a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while the message subtypes plus `workspace` created / updated / deleted invalidate the `['sessions']` / `['v2-sessions']` / `['workspaces']` queries (an archive or delete also drops the session's live activity entry, since no further messages will correct a stale badge); the session tree rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities` (live facts override the REST `activity.status`). Session/agent-grained traffic (`session.state`, entities, deltas) stays subscribe-gated server-side and never arrives on this socket; the DI view has no push feed at all — its panels poll the `/api/v1/debug` RPC. ## Dev server diff --git a/apps/kimi-inspect/README.md b/apps/kimi-inspect/README.md index 50ca8db5d75..c85ee0ed746 100644 --- a/apps/kimi-inspect/README.md +++ b/apps/kimi-inspect/README.md @@ -35,8 +35,8 @@ there is no fallback data source. workspace handlers materialize on demand). - **DI** — the engine's Service × Effect × DI debug surface, four panels fed by the App-scope debug Services (`IDebugLedgerService` / `IDebugGraphService` - / `IDebugCascadeService`) and refreshed eagerly off the `event.di.unit_changed` - WS frame: + / `IDebugCascadeService`) over the `/api/v1/debug` RPC, polling on a short + interval: - **Unit tree** — scope → unit → ledger entries (label, five-state `Pending / Activating / Active / Unloading / Failed`, uid, `pinned` flag, unit error object), with **unprovide / update / dispose** triggers. @@ -53,5 +53,5 @@ there is no fallback data source. `GET /api/v1/debug/channels` enumerates every scoped Service — there is no whitelist; new Services appear automatically. - There is no Service-event push channel besides the global events listed - above; panels fetch/refresh on demand (react-query, 15 s poll) plus the - `event.di.unit_changed` invalidation for the DI view. + above; panels fetch/refresh on demand (react-query, 15 s poll — the DI view + polls on its own short interval). diff --git a/apps/kimi-inspect/src/activity/di.ts b/apps/kimi-inspect/src/activity/di.ts deleted file mode 100644 index ed5b7e3f007..00000000000 --- a/apps/kimi-inspect/src/activity/di.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * DI debug feed — a dedicated global-events socket for the DI view. The - * session activity hub (`useSessionActivities`) lives with the chat Sidebar, - * which unmounts when the DI view is active, so the DI view owns its own - * `GlobalEventsWs` (only one of the two is ever connected at a time). - * - * Every `event.di.unit_changed` frame invalidates the `['di']` query prefix - * (the same pattern as `event.session.created` invalidating `['sessions']`), - * so all DI panels refetch on unit transitions instead of waiting out their - * poll interval. Bursts (a cascade flipping many units at once) are coalesced - * with a short trailing throttle; a reconnect invalidates immediately, since - * live transitions were missed while the socket was down. - */ - -import { useQueryClient } from '@tanstack/react-query'; -import { useEffect } from 'react'; - -import { useConnection } from '../connection'; -import { GlobalEventsWs } from './ws'; - -const INVALIDATE_THROTTLE_MS = 250; - -export function useDiQueryInvalidation(): void { - const { baseUrl, config } = useConnection(); - const queryClient = useQueryClient(); - const token = config.token.trim(); - - useEffect(() => { - let timer: ReturnType | undefined; - const invalidate = () => { - if (timer !== undefined) return; - timer = setTimeout(() => { - timer = undefined; - void queryClient.invalidateQueries({ queryKey: ['di'] }); - }, INVALIDATE_THROTTLE_MS); - }; - const ws = new GlobalEventsWs({ - url: baseUrl, - token: token === '' ? undefined : token, - handlers: { - onWorkChanged: () => {}, - onSessionCreated: () => {}, - onMetaUpdated: () => {}, - onDiUnitChanged: invalidate, - onReconnected: invalidate, - }, - }); - return () => { - if (timer !== undefined) clearTimeout(timer); - ws.close(); - }; - }, [baseUrl, token, queryClient]); -} diff --git a/apps/kimi-inspect/src/activity/store.test.ts b/apps/kimi-inspect/src/activity/store.test.ts index 3e600fe8122..018a78433af 100644 --- a/apps/kimi-inspect/src/activity/store.test.ts +++ b/apps/kimi-inspect/src/activity/store.test.ts @@ -58,6 +58,37 @@ function seedFetch(items: Record[]): typeof fetch { })) as unknown as typeof fetch; } +function sessionMessage( + subtype: 'created' | 'updated' | 'archived' | 'deleted', + session: Record & { id: string }, +): Record { + return { + type: 'session', + timestamp: Date.now(), + subtype, + session: { + workspace_id: 'wd_example_0123456789ab', + title: 'session', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + busy: false, + metadata: { cwd: '/tmp/example' }, + agent_config: { model: 'test-model' }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + context_tokens: 0, + }, + permission_rules: [], + message_count: 0, + last_seq: 0, + ...session, + }, + }; +} + describe('SessionActivityStore', () => { it('applies work facts and notifies with a version bump', () => { const store = new SessionActivityStore(); @@ -112,17 +143,13 @@ describe('SessionActivityHub', () => { expect(hub.store.get('s1')).toEqual(facts({ busy: true, mainTurnActive: true })); expect(hub.store.get('s2')?.pendingInteraction).toBe('approval'); - // The hello goes out with no subscriptions — global facts flow regardless. - const hello = JSON.parse(instances[0]!.sent[0]!) as { - type: string; - payload: { subscriptions: string[] }; - }; - expect(hello.type).toBe('client_hello'); - expect(hello.payload.subscriptions).toEqual([]); + // Nothing goes out — v3 global messages flow to every connection with no + // subscribe frame. + expect(instances[0]!.sent).toEqual([]); hub.close(); }); - it('applies live work_changed frames by session id', () => { + it('applies live session messages by session id', () => { const { ctor, instances } = makeFakeWsCtor(); const hub = new SessionActivityHub({ url: 'http://127.0.0.1:58627', @@ -132,17 +159,14 @@ describe('SessionActivityHub', () => { }); instances[0]!.emit('open'); - instances[0]!.emitFrame({ - type: 'event.session.work_changed', - session_id: 's1', - payload: { - type: 'event.session.work_changed', + instances[0]!.emitFrame( + sessionMessage('updated', { + id: 's1', busy: true, main_turn_active: true, pending_interaction: 'question', - last_turn_reason: null, - }, - }); + }), + ); expect(hub.store.get('s1')).toEqual( facts({ busy: true, mainTurnActive: true, pendingInteraction: 'question' }), @@ -150,7 +174,7 @@ describe('SessionActivityHub', () => { hub.close(); }); - it('forwards created and meta updates as list-level signals', () => { + it('forwards created and updated messages as list-level signals', () => { const { ctor, instances } = makeFakeWsCtor(); const onListChanged = vi.fn(); const hub = new SessionActivityHub({ @@ -161,17 +185,17 @@ describe('SessionActivityHub', () => { }); instances[0]!.emit('open'); - instances[0]!.emitFrame({ type: 'event.session.created', session_id: 's1', payload: {} }); - instances[0]!.emitFrame({ type: 'session.meta.updated', session_id: 's1', payload: {} }); - // Agent-grained frames are ignored even if they somehow arrive. + instances[0]!.emitFrame(sessionMessage('created', { id: 's1' })); + instances[0]!.emitFrame(sessionMessage('updated', { id: 's1' })); + // Unknown future message types are ignored silently. instances[0]!.emitFrame({ type: 'turn.started', session_id: 's1', payload: {} }); expect(onListChanged).toHaveBeenCalledTimes(2); - expect(hub.store.get('s1')).toBeUndefined(); + expect(hub.store.get('s1')).toEqual(facts()); hub.close(); }); - it('forwards archived and workspace frames as list-level signals and drops archived facts', () => { + it('forwards archived/deleted and workspace messages as list-level signals and drops gone facts', () => { const { ctor, instances } = makeFakeWsCtor(); const onListChanged = vi.fn(); const hub = new SessionActivityHub({ @@ -182,46 +206,36 @@ describe('SessionActivityHub', () => { }); instances[0]!.emit('open'); - instances[0]!.emitFrame({ - type: 'event.session.work_changed', - session_id: 's1', - payload: { type: 'event.session.work_changed', busy: true }, - }); + instances[0]!.emitFrame(sessionMessage('updated', { id: 's1', busy: true })); expect(hub.store.get('s1')).toBeDefined(); - // Global-dispatched frames carry the __global__ watermark; the real - // session id rides in the payload. - instances[0]!.emitFrame({ - type: 'event.session.archived', - session_id: '__global__', - payload: { type: 'event.session.archived', sessionId: 's1', workspace_id: 'wd_1' }, - }); + instances[0]!.emitFrame(sessionMessage('archived', { id: 's1', archived: true })); expect(hub.store.get('s1')).toBeUndefined(); - expect(onListChanged).toHaveBeenCalledTimes(1); + expect(onListChanged).toHaveBeenCalledTimes(2); - instances[0]!.emitFrame({ - type: 'event.session.work_changed', - session_id: 's2', - payload: { type: 'event.session.work_changed', busy: true }, - }); + instances[0]!.emitFrame(sessionMessage('updated', { id: 's2', busy: true })); expect(hub.store.get('s2')).toBeDefined(); - instances[0]!.emitFrame({ - type: 'event.session.deleted', - session_id: '__global__', - payload: { type: 'event.session.deleted', sessionId: 's2', workspace_id: 'wd_1' }, - }); + instances[0]!.emitFrame(sessionMessage('deleted', { id: 's2' })); expect(hub.store.get('s2')).toBeUndefined(); - expect(onListChanged).toHaveBeenCalledTimes(2); - - for (const type of [ - 'event.workspace.created', - 'event.workspace.updated', - 'event.workspace.deleted', - ]) { - instances[0]!.emitFrame({ type, session_id: '__global__', payload: {} }); + expect(onListChanged).toHaveBeenCalledTimes(4); + + for (const subtype of ['created', 'updated', 'deleted']) { + instances[0]!.emitFrame({ + type: 'workspace', + timestamp: Date.now(), + subtype, + workspace: { + id: 'wd_example_0123456789ab', + root: '/tmp/example', + name: 'example', + created_at: new Date().toISOString(), + last_opened_at: new Date().toISOString(), + session_count: 0, + }, + }); } - expect(onListChanged).toHaveBeenCalledTimes(5); + expect(onListChanged).toHaveBeenCalledTimes(7); hub.close(); }); }); diff --git a/apps/kimi-inspect/src/activity/store.ts b/apps/kimi-inspect/src/activity/store.ts index af77ad6c062..54f8948aa62 100644 --- a/apps/kimi-inspect/src/activity/store.ts +++ b/apps/kimi-inspect/src/activity/store.ts @@ -5,19 +5,30 @@ * Two data sources converge into one store: the initial / reconnect * baseline comes from a single `GET /api/v1/sessions` page (every wire * session carries `busy` / `main_turn_active` / `pending_interaction` / - * `last_turn_reason`), and live updates arrive as - * `event.session.work_changed` frames over the global WS channel (no - * subscription needed server-side). List-level facts (session created / - * retitled) are forwarded to the consumer as `onListChanged` so the - * react-query session list invalidates instead of waiting out its slow poll. - * The store is a plain subscribe/version store so React binds through + * `last_turn_reason`), and live updates arrive as `session` global messages + * over the `/api/v3/ws` channel (no subscription needed server-side — the + * embedded SessionInfo carries the same four facts). List-level facts + * (session created / retitled / archived / deleted, workspace changed) are + * forwarded to the consumer as `onListChanged` so the react-query session + * list invalidates instead of waiting out its slow poll. The store is a + * plain subscribe/version store so React binds through * `useSyncExternalStore`. */ +import type { SessionInfo, SessionMessage } from '@moonshot-ai/kap-server/protocol'; + import type { WsLikeCtor } from '../channel/wsLike'; -import { GlobalEventsWs, type SessionWorkFacts } from './ws'; +import { GlobalEventsWs } from './ws'; + +export type SessionPendingInteraction = 'none' | 'approval' | 'question'; +export type SessionTurnOutcome = 'completed' | 'cancelled' | 'failed'; -export type { SessionWorkFacts }; +export interface SessionWorkFacts { + readonly busy: boolean; + readonly mainTurnActive: boolean; + readonly pendingInteraction: SessionPendingInteraction; + readonly lastTurnReason?: SessionTurnOutcome | undefined; +} export class SessionActivityStore { private activities = new Map(); @@ -58,8 +69,8 @@ export class SessionActivityStore { this.bump(); } - /** Drop one session's live facts (e.g. it was archived — no further - * work_changed frames will arrive to correct a stale badge). */ + /** Drop one session's live facts (e.g. it was archived or deleted — no + * further messages will arrive to correct a stale badge). */ remove(sessionId: string): void { if (this.activities.delete(sessionId)) this.bump(); } @@ -99,17 +110,7 @@ export class SessionActivityHub { token: opts.token, WebSocketImpl: opts.WebSocketImpl, handlers: { - onWorkChanged: (sessionId, facts) => this.store.applyWorkChanged(sessionId, facts), - onSessionCreated: () => opts.onListChanged(), - onMetaUpdated: () => opts.onListChanged(), - onSessionArchived: (sessionId) => { - this.store.remove(sessionId); - opts.onListChanged(); - }, - onSessionDeleted: (sessionId) => { - this.store.remove(sessionId); - opts.onListChanged(); - }, + onSession: (message) => this.onSession(message, opts.onListChanged), onWorkspaceChanged: () => opts.onListChanged(), onReconnected: () => void this.seed(), }, @@ -120,6 +121,16 @@ export class SessionActivityHub { this.ws.close(); } + private onSession(message: SessionMessage, onListChanged: () => void): void { + const { subtype, session } = message; + if (subtype === 'archived' || subtype === 'deleted') { + this.store.remove(session.id); + } else { + this.store.applyWorkChanged(session.id, workFactsOf(session)); + } + onListChanged(); + } + private async seed(): Promise { const headers: Record = {}; if (this.token !== undefined && this.token.length > 0) { @@ -153,8 +164,18 @@ export class SessionActivityHub { } this.store.seed(entries); } catch { - // Seed is best-effort: live frames keep flowing, and the next reconnect - // re-seeds. A dead server surfaces through the connection layer anyway. + // Seed is best-effort: live messages keep flowing, and the next + // reconnect re-seeds. A dead server surfaces through the connection + // layer anyway. } } } + +function workFactsOf(session: SessionInfo): SessionWorkFacts { + return { + busy: session.busy, + mainTurnActive: session.main_turn_active === true, + pendingInteraction: session.pending_interaction ?? 'none', + lastTurnReason: session.last_turn_reason, + }; +} diff --git a/apps/kimi-inspect/src/activity/ws.ts b/apps/kimi-inspect/src/activity/ws.ts index 6a1887f05c4..5f480ea212a 100644 --- a/apps/kimi-inspect/src/activity/ws.ts +++ b/apps/kimi-inspect/src/activity/ws.ts @@ -1,84 +1,69 @@ /** - * Minimal `/api/v1/ws` client for GLOBAL session facts — no subscriptions. + * Minimal `/api/v3/ws` client for the GLOBAL messages — no subscriptions. * - * The server pushes every global event (`event.session.*` / - * `session.meta.updated` / `event.workspace.*` / `event.config.*`) to every - * established connection, so this client subscribes to nothing: it sends a - * `client_hello` with an empty subscription list (etiquette only — the - * delivery set does not depend on it) and dispatches the coarse per-session - * facts to the consumer: + * The server sends `hello` right after the upgrade and fans every global + * message (`session` / `workspace` / `config` / `config.warning` / + * `model_catalog` / `plugin` / `capability`) out to every established + * connection, so this client subscribes to nothing and sends nothing: it + * dispatches the coarse per-session facts to the consumer: * - * - `event.session.work_changed` → `{busy, main_turn_active, - * pending_interaction, last_turn_reason}` for one session; - * - `event.session.created` / `session.meta.updated` → list-level signals - * (a session appeared / retitled), forwarded for list invalidation; - * - `event.session.archived` (live or cold) / `event.workspace.*` → - * list-level signals, forwarded for list invalidation; - * - `event.di.unit_changed` → one DI unit state transition of the engine's - * scope tree (the debug-surface feed), forwarded for `['di']` - * invalidation. Global like the rest: it carries the `__global__` - * session watermark and fans out to every connection. + * - `session` (created / updated / archived / deleted) → forwarded whole; + * the embedded SessionInfo carries `busy` / `main_turn_active` / + * `pending_interaction` / `last_turn_reason`, and the consumer maps the + * subtype onto the activity map and the list invalidation; + * - `workspace` (created / updated / deleted) → list-level signal. * - * Session/agent-grained events never arrive here (they stay subscribe-gated - * server-side); the transcript chat channel has its own socket. Global - * frames are live-only — a drop loses whatever fired meanwhile, so the - * consumer answers `onReconnected` with a REST re-seed. + * Session/agent-grained traffic (entities, deltas, `session.state`) stays + * subscribe-gated server-side and never arrives here; the transcript chat + * channel has its own socket (`src/transcript/ws.ts`). Global messages are + * live-only — a drop loses whatever fired meanwhile, so the consumer answers + * `onReconnected` with a REST re-seed. Heartbeat is the WS protocol-level + * ping/pong, handled by the WebSocket implementation itself. + * + * Every frame is validated against the shared `serverMessageSchema`: a frame + * whose `type` is not in the schema is a future message type and is ignored + * silently, while a frame naming a known global type but failing validation + * is a server bug and surfaces via `onInvalidFrame`. * * The bearer token is presented at the upgrade through the * `kimi-code.bearer.` subprotocol (the only credential channel a * browser WebSocket has). */ -import type { WsLike, WsLikeCtor } from '../channel/wsLike'; - -export type SessionPendingInteraction = 'none' | 'approval' | 'question'; -export type SessionTurnOutcome = 'completed' | 'cancelled' | 'failed'; +import { serverMessageSchema, type SessionMessage } from '@moonshot-ai/kap-server/protocol'; -export interface SessionWorkFacts { - readonly busy: boolean; - readonly mainTurnActive: boolean; - readonly pendingInteraction: SessionPendingInteraction; - readonly lastTurnReason?: SessionTurnOutcome | undefined; -} +import type { WsLike, WsLikeCtor } from '../channel/wsLike'; -export type DiUnitState = 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed'; +const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; -/** Wire payload of the `event.di.unit_changed` global event. */ -export interface DiUnitChangedPayload { - /** Scope path of the container owning the unit (`app` / `app/workspace:` / …). */ - readonly scope: string; - readonly token: string; - readonly state: DiUnitState; - /** Serialized sticky failure, present only on a Failed transition. */ - readonly error?: string | undefined; -} +const KNOWN_GLOBAL_TYPES: ReadonlySet = new Set([ + 'session', + 'workspace', + 'config', + 'config.warning', + 'model_catalog', + 'plugin', + 'capability', + 'hello', + 'ack', + 'error', +]); export interface GlobalEventsWsHandlers { - /** Coarse work-fact tuple for one session changed. */ - onWorkChanged: (sessionId: string, facts: SessionWorkFacts) => void; - /** A session was created (list-level signal). */ - onSessionCreated: (sessionId: string) => void; - /** A session's title/patch changed (list-level signal). */ - onMetaUpdated: (sessionId: string) => void; - /** A session was archived, live or cold (list-level signal). The envelope - * carries the `__global__` watermark; the real session id rides in the - * payload. */ - onSessionArchived?: ((sessionId: string) => void) | undefined; - /** A session was permanently deleted (list-level signal). Same envelope - * shape as `event.session.archived`: the real session id rides in the - * payload. */ - onSessionDeleted?: (sessionId: string) => void; - /** A workspace was created / updated / deleted (list-level signal). */ + /** A `session` global message arrived (created / updated / archived / + * deleted); the embedded SessionInfo carries the coarse work facts. */ + onSession: (message: SessionMessage) => void; + /** A `workspace` global message arrived (created / updated / deleted). */ onWorkspaceChanged?: (() => void) | undefined; - /** A DI unit of the engine's scope tree changed state (debug feed). */ - onDiUnitChanged?: ((payload: DiUnitChangedPayload) => void) | undefined; /** Socket established (initial connect and every reconnect) — the consumer - * answers with a REST re-seed, since live facts are missed while down. */ + * answers with a REST re-seed, since live messages are missed while down. */ onReconnected: () => void; + /** A frame naming a known global type failed schema validation (server bug). */ + onInvalidFrame?: ((raw: unknown) => void) | undefined; } export interface GlobalEventsWsOptions { - /** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v1/ws` URL. */ + /** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v3/ws` URL. */ readonly url: string; readonly token?: string | undefined; readonly handlers: GlobalEventsWsHandlers; @@ -88,15 +73,6 @@ export interface GlobalEventsWsOptions { readonly reconnectDelayMs?: number; } -interface ServerFrame { - readonly type: string; - readonly id?: string; - readonly session_id?: string; - readonly payload?: unknown; -} - -const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.'; - export class GlobalEventsWs { private readonly wsUrl: string; private readonly token?: string; @@ -149,13 +125,8 @@ export class GlobalEventsWs { this.ws = ws; ws.addEventListener('open', () => { this.reconnectAttempt = 0; - this.send({ - type: 'client_hello', - id: `kimi-inspect-global-${Date.now().toString(36)}`, - payload: { client_id: 'kimi-inspect', subscriptions: [] }, - }); - // Established (first connect and every reconnect alike): live facts may - // have been missed — the consumer re-seeds from REST. + // Established (first connect and every reconnect alike): live messages + // may have been missed — the consumer re-seeds from REST. this.handlers.onReconnected(); }); ws.addEventListener('message', (event: { data: unknown }) => { @@ -173,60 +144,31 @@ export class GlobalEventsWs { } private onMessage(raw: unknown): void { - let frame: ServerFrame; + let frame: unknown; try { - frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)) as ServerFrame; + frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)); } catch { + this.handlers.onInvalidFrame?.(raw); return; } - const sessionId = frame.session_id; - if (typeof sessionId !== 'string' || sessionId === '') return; - switch (frame.type) { - case 'event.session.work_changed': { - const facts = parseWorkFacts(frame.payload); - if (facts !== undefined) this.handlers.onWorkChanged(sessionId, facts); - return; - } - case 'event.session.created': { - this.handlers.onSessionCreated(sessionId); - return; + const parsed = serverMessageSchema.safeParse(frame); + if (!parsed.success) { + const type = (frame as { readonly type?: unknown } | null)?.type; + if (typeof type === 'string' && KNOWN_GLOBAL_TYPES.has(type)) { + this.handlers.onInvalidFrame?.(frame); } - case 'event.session.archived': { - const payload = frame.payload as { sessionId?: unknown } | undefined; - const archivedId = payload?.sessionId; - if (typeof archivedId === 'string' && archivedId !== '') { - this.handlers.onSessionArchived?.(archivedId); - } - return; - } - case 'event.session.deleted': { - const payload = frame.payload as { sessionId?: unknown } | undefined; - const deletedId = payload?.sessionId; - if (typeof deletedId === 'string' && deletedId !== '') { - this.handlers.onSessionDeleted?.(deletedId); - } + return; + } + const message = parsed.data; + switch (message.type) { + case 'session': { + this.handlers.onSession(message); return; } - case 'event.workspace.created': - case 'event.workspace.updated': - case 'event.workspace.deleted': { + case 'workspace': { this.handlers.onWorkspaceChanged?.(); return; } - case 'session.meta.updated': { - this.handlers.onMetaUpdated(sessionId); - return; - } - case 'event.di.unit_changed': { - const payload = parseDiUnitChangedPayload(frame.payload); - if (payload !== undefined) this.handlers.onDiUnitChanged?.(payload); - return; - } - case 'ping': { - const nonce = (frame.payload as { nonce?: unknown } | undefined)?.nonce; - this.send({ type: 'pong', payload: { nonce } }); - return; - } default: return; } @@ -242,56 +184,9 @@ export class GlobalEventsWs { }, delay); this.reconnectTimer.unref?.(); } - - private send(frame: Record): void { - const ws = this.ws; - if (ws === undefined || ws.readyState !== this.WsCtor.OPEN) return; - try { - ws.send(JSON.stringify(frame)); - } catch { - // best-effort; the close handler handles teardown - } - } -} - -function parseWorkFacts(payload: unknown): SessionWorkFacts | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const p = payload as Record; - if (typeof p['busy'] !== 'boolean') return undefined; - const pending = p['pending_interaction']; - const reason = p['last_turn_reason']; - return { - busy: p['busy'], - mainTurnActive: p['main_turn_active'] === true, - pendingInteraction: pending === 'approval' || pending === 'question' ? pending : 'none', - lastTurnReason: - reason === 'completed' || reason === 'cancelled' || reason === 'failed' ? reason : undefined, - }; -} - -const DI_UNIT_STATES: ReadonlySet = new Set([ - 'Pending', - 'Activating', - 'Active', - 'Unloading', - 'Failed', -]); - -function parseDiUnitChangedPayload(payload: unknown): DiUnitChangedPayload | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const p = payload as Record; - if (typeof p['scope'] !== 'string' || typeof p['token'] !== 'string') return undefined; - const state = p['state']; - if (typeof state !== 'string' || !DI_UNIT_STATES.has(state)) return undefined; - return { - scope: p['scope'], - token: p['token'], - state: state as DiUnitState, - error: typeof p['error'] === 'string' ? p['error'] : undefined, - }; } -/** Derive the `/api/v1/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */ +/** Derive the `/api/v3/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */ function toWsUrl(base: string): string { const url = new URL(base); if (url.protocol === 'http:') url.protocol = 'ws:'; @@ -299,8 +194,8 @@ function toWsUrl(base: string): string { if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { throw new Error(`unsupported URL scheme for WS transport: ${base}`); } - if (!url.pathname.endsWith('/api/v1/ws')) { - url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v1/ws`; + if (!url.pathname.endsWith('/api/v3/ws')) { + url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v3/ws`; } url.search = ''; url.hash = ''; diff --git a/apps/kimi-inspect/src/channel/client.ts b/apps/kimi-inspect/src/channel/client.ts index 04eb6a76064..ac068e2282f 100644 --- a/apps/kimi-inspect/src/channel/client.ts +++ b/apps/kimi-inspect/src/channel/client.ts @@ -15,8 +15,8 @@ * name in the URL. Calls ride HTTP (`ProxyChannel`). There is no event * transport: the v2 socket (`/api/v2/ws`) that used to carry Service `onXxx` * emitters and scope event streams was removed server-side, so the UI reads - * Service state on demand. (The transcript's own `/api/v1/ws` delta channel - * lives in `src/transcript/` and is unrelated to this client.) + * Service state on demand. (The transcript's own `/api/v3/ws` message + * channel lives in `src/transcript/` and is unrelated to this client.) */ import type { ServiceProxy, ServiceRef } from './channel'; diff --git a/apps/kimi-inspect/src/channel/wsLike.ts b/apps/kimi-inspect/src/channel/wsLike.ts index 440a9e8c77f..84d960f3b27 100644 --- a/apps/kimi-inspect/src/channel/wsLike.ts +++ b/apps/kimi-inspect/src/channel/wsLike.ts @@ -1,8 +1,9 @@ /** * Minimal DOM-compatible WebSocket surface shared by the app's socket - * clients (today only the transcript `/api/v1/ws` client). Coding against - * this structural type keeps the clients testable with an injected fake; - * the default is the global `WebSocket` (browsers, Node ≥ 21). + * clients (the transcript chat client and the activity global-events + * client, both on `/api/v3/ws`). Coding against this structural type keeps + * the clients testable with an injected fake; the default is the global + * `WebSocket` (browsers, Node ≥ 21). */ export interface WsLike { readonly readyState: number; diff --git a/apps/kimi-inspect/src/components/DiInspectionView.tsx b/apps/kimi-inspect/src/components/DiInspectionView.tsx index 93001b1b708..db52213a08b 100644 --- a/apps/kimi-inspect/src/components/DiInspectionView.tsx +++ b/apps/kimi-inspect/src/components/DiInspectionView.tsx @@ -21,9 +21,9 @@ * - Pending: the waiting area + sticky failures per scope * (`IDebugCascadeService.pending`), with an `update` retry per failure. * - * All five panels poll on a short interval and refresh eagerly when the - * global `event.di.unit_changed` WS frame fires (`useDiQueryInvalidation` - * invalidates the `['di']` query prefix). + * All five panels poll on a short interval over the `/api/v1/debug` RPC + * surface, and a settled unit trigger invalidates the `['di']` query prefix + * so every panel converges. */ import type { UnitState } from '@moonshot-ai/agent-core-v2/_base/di/cascadeEngine'; @@ -46,7 +46,6 @@ import { import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useState, type ReactNode } from 'react'; -import { useDiQueryInvalidation } from '../activity/di'; import type { InspectClient } from '../channel'; import { useConnection } from '../connection'; import { ActionButton, Badge, ErrorLine } from '../ui'; @@ -66,7 +65,6 @@ const PANELS: readonly { id: DiPanel; title: string }[] = [ const REFETCH_INTERVAL_MS = 3000; export function DiInspectionView() { - useDiQueryInvalidation(); const [panel, setPanel] = useState('units'); return (
diff --git a/docs/en/guides/web.md b/docs/en/guides/web.md index 18c97a81cbe..8ec21849697 100644 --- a/docs/en/guides/web.md +++ b/docs/en/guides/web.md @@ -91,6 +91,6 @@ Make sure you started with `--host` (bare is fine), and use the LAN URL from the ## Next steps -- [Server API](../reference/server-api.md) — REST / WebSocket APIs for scripts and third-party integrations (experimental) +- [Server API](../reference/server-api.md) — REST API for scripts and third-party integrations (experimental) - [kimi command](../reference/kimi-command.md#kimi-web) — all `kimi web` command-line options - [Remote Control](./remote-control.md) — remotely view and take over local sessions from any device over the public internet diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 280e8fe9575..690bcde1072 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -157,7 +157,7 @@ kimi acp Run the local Kimi server in the foreground of the current terminal — a single process that exposes the REST + WebSocket API and serves the web UI from the same origin — and open the web UI in the default browser once it is ready. The command stays attached to the terminal and shuts down cleanly on `SIGINT` / `SIGTERM` (e.g. `Ctrl-C`). -When the server is running, `GET /openapi.json` returns the REST OpenAPI document and `GET /asyncapi.json` returns the local WebSocket AsyncAPI document. For an end-to-end walkthrough of driving sessions over the API, see [Server API: Drive a session over the API](./server-api.md#drive-a-session-over-the-api); for the protocol details, see the [Server API](./server-api.md) reference. +When the server is running, `GET /openapi.json` returns the REST OpenAPI document. For an end-to-end walkthrough of driving sessions over the API, see [Server API: Drive a session over the API](./server-api.md#drive-a-session-over-the-api); for the protocol details, see the [Server API](./server-api.md) reference. ```sh kimi web # run the server in the foreground and open the browser diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index 5881fa6122a..2c2e8072d95 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -1,11 +1,11 @@ # Server API -The local server started by `kimi web` exposes two programmatic surfaces: a REST API (`/api/v1`, plus `/api/v2/sessions` and `/api/v2/mcp`) and a WebSocket event stream (`/api/v1/ws`). This page is the protocol reference for both. For how to start the server and its command-line options, see the [kimi command](./kimi-command.md#kimi-web) reference; for an end-to-end walkthrough, see [Drive a session over the API](#drive-a-session-over-the-api) below. +The local server started by `kimi web` exposes a REST API (`/api/v1`, plus `/api/v2/sessions` and `/api/v2/mcp`). This page is its protocol reference. For how to start the server and its command-line options, see the [kimi command](./kimi-command.md#kimi-web) reference; for an end-to-end walkthrough, see [Drive a session over the API](#drive-a-session-over-the-api) below. -This page is a curated, human-readable reference: it documents every endpoint's parameters, request bodies, and response shapes below. The precise machine-readable schema of every endpoint is owned by the server's live specification documents: `GET /openapi.json` (OpenAPI) and `GET /asyncapi.json` (AsyncAPI), both generated from the same validation schemas the server enforces at runtime. Both require authentication; when this page and the live spec ever disagree, the live spec wins. +This page is a curated, human-readable reference: it documents every endpoint's parameters, request bodies, and response shapes below. The precise machine-readable schema of every endpoint is owned by the server's live specification document: `GET /openapi.json` (OpenAPI), generated from the same validation schemas the server enforces at runtime. It requires authentication; when this page and the live spec ever disagree, the live spec wins. ::: warning -The REST and WebSocket APIs described on this page are experimental: interface stability is not guaranteed, and endpoints, fields, and event types may change in any release. When integrating, rely on the `/openapi.json` and `/asyncapi.json` documents served by your version. +The REST API described on this page is experimental: interface stability is not guaranteed, and endpoints and fields may change in any release. When integrating, rely on the `/openapi.json` document served by your version. ::: ## Conventions @@ -16,7 +16,7 @@ The default address is `http://127.0.0.1:58627`. When the port is taken, the ser ### Authentication -All `/api/*` paths (including `/openapi.json` and `/asyncapi.json`) require the bearer token, except: +All `/api/*` paths (including `/openapi.json`) require the bearer token, except: - `OPTIONS` preflight requests - `GET /api/v1/healthz` (liveness probe) @@ -76,12 +76,12 @@ Error codes are grouped by band: List endpoints come in two styles: -- **Cursor style**: `before_id` / `after_id` (mutually exclusive) plus `page_size` (1–100), responding with `{ items, has_more }`. Used by the session list, message list, transcript, and others. +- **Cursor style**: `before_id` / `after_id` (mutually exclusive) plus `page_size` (1–100), responding with `{ items, has_more }`. Used by the session list and others. - **`page_token`**: an opaque token (bound to a fingerprint of the query conditions), used by `POST /api/v1/search` and `GET /api/v2/sessions`. Changing any query condition mid-pagination invalidates the token: v2 returns `40922`, search returns `40001`. `GET /api/v2/sessions` also offers a stateless `page` page-number mode as an alternative. ## Drive a session over the API -The minimal flow with curl: check the server → create a session → subscribe to events → submit a prompt → read history back. The examples assume the server runs at the default address and the token is stored in the shell variable `TOKEN`. +The minimal flow with curl: check the server → create a session → submit a prompt → read the session state back. The examples assume the server runs at the default address and the token is stored in the shell variable `TOKEN`. 1. Check server status: @@ -102,25 +102,7 @@ curl -s -X POST http://127.0.0.1:58627/api/v1/sessions \ The returned `data.id` (shaped like `session_...`) is the session id used by every subsequent request. -3. Connect to the WebSocket and subscribe to session events. Any WebSocket client works; below is a dependency-free Node.js script (Node.js 22+ ships a built-in `WebSocket` client): - -```js -// subscribe.mjs — usage: TOKEN=... node subscribe.mjs session_... -const ws = new WebSocket('ws://127.0.0.1:58627/api/v1/ws', [ - `kimi-code.bearer.${process.env.TOKEN}`, -]); -ws.onmessage = (e) => console.log(e.data); -ws.onopen = () => - ws.send( - JSON.stringify({ - type: 'subscribe', - id: '1', - payload: { session_ids: [process.argv[2]] }, - }), - ); -``` - -4. Submit a prompt: +3. Submit a prompt: ```sh curl -s -X POST http://127.0.0.1:58627/api/v1/sessions//prompts \ @@ -129,15 +111,15 @@ curl -s -X POST http://127.0.0.1:58627/api/v1/sessions//prompts \ -d '{"content": [{"type": "text", "text": "Introduce this repository in one sentence"}]}' ``` -The subscriber sees, in order: `turn.started` (turn begins) → `assistant.delta` (streaming text increments) → `tool.call.started` / `tool.result` when tool calls happen → `turn.ended` (turn finishes). - -5. Read history back over REST at any time: +4. Read the session's realtime status back over REST at any time: ```sh curl -s -H "Authorization: Bearer $TOKEN" \ - "http://127.0.0.1:58627/api/v1/sessions//messages?page_size=20" + "http://127.0.0.1:58627/api/v1/sessions//status" ``` +The rollup reports `busy` while the turn runs, together with the effective model and context usage. + ## REST endpoints Endpoints are grouped by resource below. A `:{action}` suffix in a path is the action convention — POST to `path:action` on a single resource for non-CRUD operations (such as `:fork` and `:archive` on a session). @@ -274,7 +256,7 @@ On success, `data` is `{ region }` with `region` one of `mainland-cn` / `global` | Method and path | Description | | --- | --- | | `GET /api/v1/config` | Read the global config (secret fields redacted) | -| `POST /api/v1/config` | Merge-patch the config; broadcasts `event.config.changed` | +| `POST /api/v1/config` | Merge-patch the config | #### `GET /api/v1/config` @@ -310,8 +292,6 @@ On success, `data` is the config object; its fields mirror the top-level domains Merge-patches the global configuration: each top-level domain in the body is deep-merged into that domain, and domains absent from the body are left untouched. Setting `yolo` to `true` is shorthand for `default_permission_mode: "yolo"`; a rejected patch (invalid value or persistence failure) returns `40001` with the underlying message. -Every config change — a successful update through this endpoint, an external edit of `config.toml`, or a server-side write such as an OAuth login refresh — is broadcast as the global `event.config.changed` event. Changes inside a short window are merged into one event carrying the affected domain names in `changedFields` (camelCase config domains, for example `defaultModel`) and the full current config projection in `config` (same shape as the `GET /api/v1/config` response). - The body is a partial config object — any subset of the response domains above except `raw`, all optional: | Parameter | In | Type | Description | @@ -469,7 +449,7 @@ On success the server answers 204 with no body — the status line itself report #### `POST /api/v1/providers/{provider_id}:refresh` -Re-discovers one provider's model metadata from its upstream source and rewrites the provider's aliases. Providers with a static model source are reported `unchanged` without any network call. When at least one provider's aliases change, the server broadcasts the global `event.model_catalog.changed` event. +Re-discovers one provider's model metadata from its upstream source and rewrites the provider's aliases. Providers with a static model source are reported `unchanged` without any network call. | Parameter | In | Type | Description | | --- | --- | --- | --- | @@ -568,7 +548,6 @@ These endpoints create, list, and inspect sessions, drive session-level actions | `GET /api/v1/sessions/{session_id}/runtime` | Read the main agent's runtime binding | | `POST /api/v1/sessions/{session_id}/runtime` | Switch the main agent's runtime binding | | `POST /api/v1/sessions/{session_id}/export` | Export the session with diagnostics (zip stream, not enveloped) | -| `GET /api/v1/sessions/{session_id}/snapshot` | Full snapshot for client rebuilds (with `as_of_seq` and `epoch`) | | `GET /api/v1/sessions/{session_id}/media/{file_id}` | Download prompt media by file id (binary) | #### The session object @@ -589,15 +568,14 @@ Every endpoint that returns a session uses this wire shape. The live facts (`bus | `last_turn_reason` | string | Main agent's latest turn outcome: `completed` / `cancelled` / `failed` | | `last_prompt` | string | Most recent user prompt text, when present | | `metadata` | object | Custom metadata; always carries `cwd` (the session's working directory) | -| `agent_config` | object | Projected as `{ model }`; `model` is `""` in most responses and only filled with the live model by `GET /api/v1/sessions/{session_id}/snapshot` | -| `usage` | object | Token rollup `{ input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, context_tokens, context_limit?, total_cost_usd?, turn_count? }`; all zeros outside the snapshot endpoint | +| `agent_config` | object | Projected as `{ model }`; `model` is currently always `""` | +| `usage` | object | Token rollup `{ input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, context_tokens, context_limit?, total_cost_usd?, turn_count? }`; currently all zeros | | `permission_rules` | array | Session permission rules; currently always `[]` | | `message_count` | integer | Message count; currently always `0` | -| `last_seq` | integer | Last event sequence number; currently always `0` | #### `POST /api/v1/sessions` -Creates a session and returns it. The target directory comes from `workspace_id` (an already-registered workspace) or from `metadata.cwd` (the workspace is registered on first use); passing both requires them to agree. Creation broadcasts the global `event.session.created` event. +Creates a session and returns it. The target directory comes from `workspace_id` (an already-registered workspace) or from `metadata.cwd` (the workspace is registered on first use); passing both requires them to agree. | Parameter | In | Type | Description | | --- | --- | --- | --- | @@ -658,7 +636,7 @@ On success, `data` is [the session object](#the-session-object). #### `POST /api/v1/sessions/{session_id}/profile` -Updates the session's profile: title, custom metadata, and the main agent's config. A title set here becomes a custom title, which wins over generated titles; setting one broadcasts the global `session.meta.updated` event. +Updates the session's profile: title, custom metadata, and the main agent's config. A title set here becomes a custom title, which wins over generated titles. | Parameter | In | Type | Description | | --- | --- | --- | --- | @@ -687,7 +665,7 @@ On success, `data` is the updated [session object](#the-session-object). #### `POST /api/v1/sessions/{session_id}/title/generate` -Generates a title from the session's prompts through the managed provider's `chat_title` tool and applies it, broadcasting `session.meta.updated`. Generation requires the managed OAuth login and the `auto_session_title` experimental flag; without `force`, a session that already has a custom or generated title is reported unavailable instead of being overwritten. +Generates a title from the session's prompts through the managed provider's `chat_title` tool and applies it. Generation requires the managed OAuth login and the `auto_session_title` experimental flag; without `force`, a session that already has a custom or generated title is reported unavailable instead of being overwritten. | Parameter | In | Type | Description | | --- | --- | --- | --- | @@ -706,7 +684,7 @@ Session actions are dispatched through one route: the path tail is parsed as `{s #### `POST /api/v1/sessions/{session_id}:fork` -Copies the session — its transcript, agent state, and files — into a new session in the same workspace, and broadcasts `event.session.created`. Forking is rejected while any of the session's agents has an active turn. +Copies the session — its transcript, agent state, and files — into a new session in the same workspace. Forking is rejected while any of the session's agents has an active turn. | Parameter | In | Type | Description | | --- | --- | --- | --- | @@ -719,7 +697,7 @@ On success, `data` is [the session object](#the-session-object) of the new sessi #### `POST /api/v1/sessions/{session_id}:compact` -Starts a manual full compaction of the main agent's context. The call returns immediately; progress and completion are delivered as the `compaction.*` WebSocket events. +Starts a manual full compaction of the main agent's context. The call returns immediately. | Parameter | In | Type | Description | | --- | --- | --- | --- | @@ -757,7 +735,7 @@ On success, `data` is `{ agent_id }` — the id of the new child agent. #### `POST /api/v1/sessions/{session_id}:archive` -Marks the session archived: it disappears from the default session list (it stays listed with `include_archive` or `archived_only`), and the server broadcasts the global `event.session.archived` event. +Marks the session archived: it disappears from the default session list (it stays listed with `include_archive` or `archived_only`). On success, `data` is `{ archived: true }`. @@ -793,7 +771,7 @@ Creates a child session: a fork of this session recorded as its child, so it sho | `title` | body | string | Title for the child (at least 1 character). Default `Child: ` | | `metadata` | body | object | Custom metadata for the child | -On success, `data` is [the session object](#the-session-object) of the new session, and the server broadcasts `event.session.created`. +On success, `data` is [the session object](#the-session-object) of the new session. - `40901`: the session has an active turn and cannot be forked @@ -869,18 +847,6 @@ Exports the session together with diagnostic logs as a zip attachment (`kimi-ses | `web_log` | body | string | Client log text to include in the archive, at most 256 KB UTF-8 | | `desktop` | body | boolean | Also include the desktop host's log. Default `false` | -#### `GET /api/v1/sessions/{session_id}/snapshot` - -Assembles an atomic snapshot for rebuilding a client after a resync: the session, recent messages, the in-flight turn, live subagents, and pending interactions, all stamped with the `as_of_seq` watermark and `epoch` used to resubscribe — see [Reconnect and recovery](#reconnect-and-recovery). Unlike the plain session endpoints, the embedded session carries the live `agent_config.model` and real `usage` totals. - -| Parameter | In | Type | Description | -| --- | --- | --- | --- | -| `session_id` | path | string | **Required.** Session id | - -On success, `data` is `{ as_of_seq, epoch, session, messages, in_flight_turn, subagents?, pending_approvals, pending_questions }`: `session` is [the session object](#the-session-object), `messages` is the newest 100 messages as `{ items, has_more }`, `in_flight_turn` is the partially streamed turn (`null` when idle, with `current_prompt_id` when known), `subagents` lists live subagent tasks, and `pending_approvals` / `pending_questions` carry the unanswered interactions. - -- `40401`: session not found - #### `GET /api/v1/sessions/{session_id}/media/{file_id}` Downloads a prompt media file (an image or other attachment referenced by the session's prompts) by file id; an id not yet committed to the session falls back to the staged uploads. The response is binary with `Range` support (206 on ranged requests) — see [Binary and streaming endpoints](#binary-and-streaming-endpoints) for the shared conventions; unlike the enveloped endpoints there, a missing session or file answers with a real 404 status carrying an envelope body. @@ -890,115 +856,9 @@ Downloads a prompt media file (an image or other attachment referenced by the se | `session_id` | path | string | **Required.** Session id | | `file_id` | path | string | **Required.** Media file id | -### Messages and transcript - -The `messages` endpoints page the main agent's flattened message history, while the `transcript` endpoints serve the structured per-agent transcript — turns, tasks, interactions, attachments — that the WebSocket [Transcript protocol](#transcript-protocol) streams live. Use these endpoints for history paging and catch-up, and the WebSocket subscription for the live tail. - -| Method and path | Description | -| --- | --- | -| `GET /api/v1/sessions/{session_id}/messages` | Page messages (`before_id` / `after_id` / `role`) | -| `GET /api/v1/sessions/{session_id}/messages/{message_id}` | Read one message | -| `GET /api/v1/sessions/{session_id}/transcript` | Turn-paged transcript (requires `agent_id`); global state rides along unpaginated | -| `GET /api/v1/sessions/{session_id}/transcript/ops` | Op-batch catch-up (`since_seq`); `complete: false` means a full refresh is needed | -| `GET /api/v1/sessions/{session_id}/transcript/user-messages` | Turn-opening user inputs, unpaginated | -| `GET /api/v1/sessions/{session_id}/transcript/plan` | ExitPlanMode plan content, path, and review outcome | - -#### `GET /api/v1/sessions/{session_id}/messages` - -Pages the main agent's message history — the flattened context transcript shared with the session snapshot — newest first. Cursor pagination follows [Pagination](#pagination); reading the history resumes the session when it is cold. - -| Parameter | In | Type | Description | -| --- | --- | --- | --- | -| `session_id` | path | string | **Required.** Session id | -| `before_id` | query | string | Only messages older than this message id; mutually exclusive with `after_id` | -| `after_id` | query | string | Only messages newer than this message id; mutually exclusive with `before_id` | -| `page_size` | query | integer | 1–100. Default `50` | -| `role` | query | string | Keep only one role: `user` / `assistant` / `tool` / `system`. The filter applies after the page is sliced, so a filtered page can hold fewer than `page_size` items while `has_more` is still `true` — keep paging until `has_more` is `false` | - -On success, `data` is `{ items, has_more }` where each item is a message object `{ id, session_id, role, content, created_at, prompt_id?, parent_message_id?, metadata? }`; `content` is an array of content parts in the wire format documented under [Prompts](#prompts) (`text`, `tool_use`, `tool_result`, `image`, `video`, `file`, `thinking`). - -- `40001`: validation failure — for example `before_id` combined with `after_id` -- `40401`: session not found - -#### `GET /api/v1/sessions/{session_id}/messages/{message_id}` - -Reads one message from the same history by id. - -| Parameter | In | Type | Description | -| --- | --- | --- | --- | -| `session_id` | path | string | **Required.** Session id | -| `message_id` | path | string | **Required.** Message id | - -On success, `data` is the message object in the item shape documented under `GET /api/v1/sessions/{session_id}/messages` above. - -- `40401`: session not found -- `40403`: no message with that id in this session - -#### `GET /api/v1/sessions/{session_id}/transcript` - -Returns one page of an agent's structured transcript: turns (with their steps and frames) plus the markers and task references between them. Live sessions answer from the in-memory store (the requested agent's persisted history is backfilled first); cold sessions rebuild the agent from the persisted wire records. This is the history half of the transcript surface — the live streaming half is the [Transcript protocol](#transcript-protocol) subscription. - -| Parameter | In | Type | Description | -| --- | --- | --- | --- | -| `session_id` | path | string | **Required.** Session id | -| `agent_id` | query | string | **Required.** Agent whose transcript to read; must be a plain agent id (letters, digits, `.`, `_`, `-` — no path separators) | -| `before_turn` | query | string | Only turns older than this turn id; mutually exclusive with `after_turn` | -| `after_turn` | query | string | Only turns newer than this turn id; mutually exclusive with `before_turn` | -| `page_size` | query | integer | 1–100 turns. Default `20` | - -The page unit is the turn: without a cursor the newest page is returned, and `has_more` reports that older turns remain. On success, `data` is `{ agent_id, items, has_more, tasks, interactions, attachments, todos, meta, agents, pending_interactions, seq? }` — `items` is the paged turn slice, `tasks` / `interactions` / `attachments` / `todos` / `meta` / `agents` / `pending_interactions` are global agent state that ships unpaginated with every response, and `seq` is the agent's op-batch watermark for resuming the stream (live sessions only). - -- `40001`: validation failure — `before_turn` combined with `after_turn`, or a non-plain `agent_id` -- `40401`: session not found - -#### `GET /api/v1/sessions/{session_id}/transcript/ops` - -Serves point-to-point catch-up from the server's op journal: the journaled op batches with `seq > since_seq` for one agent, oldest first. It is the REST counterpart of the `transcript_since` resume cursor described in [Transcript protocol](#transcript-protocol) and shares the same bounded journal, so the same fallback rule applies. - -| Parameter | In | Type | Description | -| --- | --- | --- | --- | -| `session_id` | path | string | **Required.** Session id | -| `agent_id` | query | string | **Required.** Agent id (plain id, same constraint as the transcript endpoint) | -| `since_seq` | query | integer | **Required.** The caller's last applied op-batch seq, minimum `0`; batches above it are returned | - -On success, `data` is `{ agent_id, batches, latest_seq, complete }`, each batch `{ seq, ops }`. `complete: true` means every batch up to `latest_seq` is present; `complete: false` means the journal no longer reaches back to `since_seq` (or the session is not live at all), and the caller must fall back to a full `GET .../transcript` refresh. - -- `40001`: validation failure -- `40401`: session not found - -#### `GET /api/v1/sessions/{session_id}/transcript/user-messages` - -Lists every turn-opening input of the session, grouped per agent and unpaginated: real user text, user-slash skill and plugin commands, and cron prompts — distinguishable via `origin` — plus attachment-only prompts projected with an empty `prompt`. Attachment entities referenced by the listed messages ride along (metadata only, never bytes). - -| Parameter | In | Type | Description | -| --- | --- | --- | --- | -| `session_id` | path | string | **Required.** Session id | -| `agent_id` | query | string | Read one agent only (plain id). Default reads every rostered agent | - -On success, `data` is `{ agents }` where each entry is `{ agent_id, messages, attachments }`; a message is `{ turn_id, ordinal, state, origin, prompt, attachment_ids?, started_at? }` with `state` the turn state (`queued` / `running` / `completed` / `failed` / `cancelled`). - -- `40001`: validation failure — a non-plain `agent_id` -- `40401`: session not found - -#### `GET /api/v1/sessions/{session_id}/transcript/plan` - -Reads the plan information of an agent's `ExitPlanMode` tool calls — plan content, plan file path, offered options, and the review outcome — in timeline order. Content is projected from the first available fact: the linked approval interaction (interactive reviews), the live tool frame's display (auto mode), or the tool result output text; each entry records which one in `source`. - -| Parameter | In | Type | Description | -| --- | --- | --- | --- | -| `session_id` | path | string | **Required.** Session id | -| `agent_id` | query | string | **Required.** Agent id (plain id) | -| `tool_call_id` | query | string | Narrow the read to one `ExitPlanMode` call; absent lists every call with recoverable plan content | - -On success, `data` is `{ agent_id, plans }` where each plan is `{ tool_call_id, turn_id, source, plan, path?, options?, review? }`: `source` is `interaction` / `display` / `output`, `options` are the review choices as `{ label, description? }`, and `review` (present only for interactive reviews) is `{ state, selected_option?, feedback? }` with `state` one of `pending` / `approved` / `rejected` / `cancelled`. - -- `40001`: validation failure -- `40401`: session not found -- `40416`: `tool_call_id` given, but no `ExitPlanMode` call with that id exists - ### Prompts -A prompt is one unit of user input: submitting one enqueues it on the session's main agent (or a named agent), a queued prompt can be steered into the active turn, and a running prompt can be aborted. Turn progress itself streams over the WebSocket [events](#events), not these endpoints. +A prompt is one unit of user input: submitting one enqueues it on the session's main agent (or a named agent), a queued prompt can be steered into the active turn, and a running prompt can be aborted. | Method and path | Description | | --- | --- | @@ -1108,7 +968,7 @@ On success, `data` is `{ steered: true, prompt_ids: [prompt_id] }`. ### Approvals and questions -Approvals and questions are the session's two pending-interaction kinds: an approval asks permission for a tool call, a question asks for structured input with labeled options. These endpoints list and resolve them; new requests arrive over the WebSocket as `event.approval.requested` and `event.question.requested`. +Approvals and questions are the session's two pending-interaction kinds: an approval asks permission for a tool call, a question asks for structured input with labeled options. These endpoints list and resolve them. | Method and path | Description | | --- | --- | @@ -1483,7 +1343,7 @@ On success, `data` is `{ ok: true }`. ### Terminals -PTY terminal endpoints; mounted only on loopback binds (a non-loopback bind skips them unless `--allow-remote-terminals` is passed). Terminal input, output, and resize flow over WebSocket `terminal_*` frames — the REST surface manages the terminal lifecycle only. +PTY terminal endpoints; mounted only on loopback binds (a non-loopback bind skips them unless `--allow-remote-terminals` is passed). The REST surface manages the terminal lifecycle only. | Method and path | Description | | --- | --- | @@ -1500,7 +1360,7 @@ Lists the session's terminals. Reading the list resumes the session when it is c | --- | --- | --- | --- | | `session_id` | path | string | **Required.** Session id | -On success, `data` is `{ items }` where each item is a terminal object `{ id, session_id, cwd, shell, cols, rows, status, created_at, exited_at?, exit_code? }`: `status` is `running` / `exited`, and an exited terminal carries `exited_at` plus `exit_code` (`null` when the process reported none, for example after a signal). Scrollback is not part of the object — output replays and streams over the WebSocket. +On success, `data` is `{ items }` where each item is a terminal object `{ id, session_id, cwd, shell, cols, rows, status, created_at, exited_at?, exit_code? }`: `status` is `running` / `exited`, and an exited terminal carries `exited_at` plus `exit_code` (`null` when the process reported none, for example after a signal). Scrollback is not part of the object. - `40401`: session not found @@ -1569,7 +1429,7 @@ Workspaces are the registered project directories sessions live in. These endpoi #### The workspace object -Every endpoint that returns a workspace uses this wire shape. Registration and rename broadcast the global `event.workspace.created` / `event.workspace.updated` events. +Every endpoint that returns a workspace uses this wire shape. | Field | Type | Description | | --- | --- | --- | @@ -1588,7 +1448,7 @@ On success, `data` is `{ items }` where each item is [the workspace object](#the #### `POST /api/v1/workspaces` -Registers a workspace and returns it. Registration is idempotent on the root path: registering an already-registered root returns the existing workspace with only `last_opened_at` refreshed (the stored name is kept), broadcasting `event.workspace.updated` instead of `event.workspace.created`. +Registers a workspace and returns it. Registration is idempotent on the root path: registering an already-registered root returns the existing workspace with only `last_opened_at` refreshed (the stored name is kept). | Parameter | In | Type | Description | | --- | --- | --- | --- | @@ -2335,68 +2195,6 @@ The OAuth flow lifecycle for remote servers. `auth:begin` takes a locator body ( - `40408`: (`:begin` / `:reset`) the locator matches nothing - `40929`: the OAuth flow itself failed -## WebSocket protocol - -### Connect - -The only endpoint is `ws://:/api/v1/ws`; authentication happens at the upgrade request (see [Authentication](#authentication) above). Once connected, the server immediately sends `server_hello`: - -```json -{ - "type": "server_hello", - "timestamp": "2026-01-01T00:00:00.000Z", - "payload": { - "ws_connection_id": "conn_01JZX4...", - "protocol_version": 2, - "max_event_buffer_size": 1000, - "capabilities": { "event_batching": false, "compression": false } - } -} -``` - -Note that the server never sends heartbeats and never disconnects an idle connection — keepalive and reconnection are the client's job. - -### Control frames - -Clients send JSON frames `{ "type", "id"?, "payload" }`; every request frame gets an acknowledgement `{ "type": "ack", "id", "code", "msg", "payload" }`, where `code` 0 means success. - -| Frame | payload | Description | -| --- | --- | --- | -| `subscribe` | `{ session_ids, cursors?, agent_filter? }` | Subscribe to session events; with `cursors` (per-session `{seq, epoch}`) the server replays missed durable events | -| `unsubscribe` | `{ session_ids }` | Drop session subscriptions | -| `subscribe_v2` | `{ session_id, transcript, transcript_since? }` | Subscribe to transcript streams (the only transcript channel); `transcript` sets per-agent grades | -| `unsubscribe_v2` | `{ session_id, agent_ids? }` | Detach transcript streams; omitting `agent_ids` means the whole session | -| `client_hello` | `{ client_id }` | Handshake frame; the remaining fields are legacy compatibility | - -### Events - -Event frames look like `{ "type", "seq", "epoch"?, "volatile"?, "offset"?, "session_id"?, "timestamp", "payload" }`, where `type` is the event type itself. Two delivery scopes: - -- **Global events**: sent to every established connection, no subscription needed — `session.meta.updated`, `event.session.created`, `event.session.archived`, `event.session.work_changed`, `event.session.status_changed`, `event.workspace.*`, `event.config.*`, `event.model_catalog.*`. -- **Session events**: sent only to connections subscribed to that session, subject to `agent_filter`. Main families: - -| Family | Main events | -| --- | --- | -| Turns | `turn.started`, `turn.ended`, `turn.step.started` / `completed` / `interrupted` / `retrying` | -| Streaming text | `assistant.delta`, `thinking.delta` (carry `offset` for alignment) | -| Tool calls | `tool.call.started`, `tool.call.delta`, `tool.progress`, `tool.result` | -| Interactions | `event.approval.requested` / `resolved`, `event.question.requested` / `answered` / `dismissed` | -| Subagents | `subagent.spawned` / `started` / `suspended` / `completed` / `failed` | -| Background | `task.started` / `terminated`, `shell.started` / `output` / `completed` | -| Misc | `compaction.*`, `skill.activated`, `goal.updated`, `prompt.*`, `error`, `warning` | - -Three global lifecycle events keep a cross-workspace overview fresh without polling per workspace. `event.session.archived` fires on both the live and the cold archive path; its envelope `session_id` is the global watermark `__global__` and the real session id rides in the payload: `{ "type": "event.session.archived", "workspace_id": "wd_...", "sessionId": "session_..." }` (payload keys `workspace_id` / `sessionId`). `event.workspace.created` / `updated` carry the full workspace object (`{ id, root, name, created_at, last_opened_at, session_count }` — an `updated` also fires when a session creation touches the workspace), and `event.workspace.deleted` carries `{ "workspace_id", "root" }`. These events only cover changes made inside this server process; changes from other processes (for example a CLI writing to the same home) surface through the index reconciliation (about a minute), so overview clients should keep a low-frequency fallback poll. There is no session-deleted event. - -Events also split into durable and volatile: durable events carry a strictly increasing `seq`, are journaled, and can be replayed; volatile events (the `*.delta` family, `tool.progress`, `shell.*`, and similar) are marked `volatile: true` and never replayed. When consuming a volatile text stream, compare `offset` (the cumulative character offset within the turn) against your locally accumulated text: below the local length means a duplicate frame; above means a gap that needs snapshot recovery. - -### Reconnect and recovery - -After reconnecting, pass each session's last applied `{seq, epoch}` in `subscribe`'s `cursors`; the server replays the gap. If you fall more than the buffer (1000 events) behind, or the cursor is no longer valid, you get `resync_required` instead. In that case, call `GET /api/v1/sessions/{session_id}/snapshot` for a full snapshot (with `as_of_seq` and `epoch`), then subscribe again with the fresh cursor. - -### Transcript protocol - -`subscribe_v2`'s `transcript` field sets a per-agent grade: `off` / `turn` / `block` / `delta` (the `"*"` key sets the default grade), with higher grades pushing finer detail. An agent with a non-`off` grade receives two frame types: `transcript.reset` (a baseline snapshot; history pages in over REST) and `transcript.ops` (incremental op batches with a per-agent strictly increasing `seq`). The agent's legacy events are suppressed on that connection and carried by transcript frames instead. After a disconnect, resume with `transcript_since`; when the server's op journal cannot cover the gap (REST catch-up returns `complete: false`), do a full refresh. The REST counterparts are `GET .../transcript` (turn-paged) and `GET .../transcript/ops?since_seq=` (op-batch catch-up). - ## Binary and streaming endpoints The following endpoints stream binary bodies instead of a JSON payload. Their HTTP capabilities differ per endpoint: diff --git a/docs/zh/guides/web.md b/docs/zh/guides/web.md index 7dc4c623d34..ee243936576 100644 --- a/docs/zh/guides/web.md +++ b/docs/zh/guides/web.md @@ -93,6 +93,6 @@ Web 里的斜杠命令与 CLI 不完全一致,支持常用指令 `/new`、`/go ## 下一步 -- [服务 API](../reference/server-api.md) — 面向脚本与第三方集成的 REST / WebSocket 接口(实验性) +- [服务 API](../reference/server-api.md) — 面向脚本与第三方集成的 REST 接口(实验性) - [kimi 命令](../reference/kimi-command.md#kimi-web) — `kimi web` 的全部命令行选项 - [远程控制](./remote-control.md) — 从公网任意设备远程查看和接管本机会话 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 5b9483918a1..255bcb5e53b 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -157,7 +157,7 @@ kimi acp 在当前终端前台运行本地 Kimi 服务 —— 同一个进程同时挂载 REST + WebSocket API 与 web UI —— 并在服务就绪后用默认浏览器打开 web UI。命令会一直挂在终端,直到收到 `SIGINT` / `SIGTERM`(如 `Ctrl-C`)时干净退出。 -服务运行时,`GET /openapi.json` 会返回 REST OpenAPI 文档,`GET /asyncapi.json` 会返回本地 WebSocket 协议的 AsyncAPI 文档。用 API 驱动会话的完整流程见[服务 API:用 API 驱动一个会话](./server-api.md#用-api-驱动一个会话),协议细节见[服务 API](./server-api.md)。 +服务运行时,`GET /openapi.json` 会返回 REST OpenAPI 文档。用 API 驱动会话的完整流程见[服务 API:用 API 驱动一个会话](./server-api.md#用-api-驱动一个会话),协议细节见[服务 API](./server-api.md)。 ```sh kimi web # 前台运行服务并打开浏览器 diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 6f6d8175827..5b7e21bf4b6 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -1,11 +1,11 @@ # 服务 API -`kimi web` 启动的本地服务暴露两组程序化接口:REST API(`/api/v1`,另有 `/api/v2/sessions` 和 `/api/v2/mcp`)和 WebSocket 事件流(`/api/v1/ws`)。本页是这两组接口的协议参考。如何启动服务及其命令行选项见 [kimi 命令](./kimi-command.md#kimi-web) 参考;端到端的上手流程见下文「[用 API 驱动一个会话](#用-api-驱动一个会话)」。 +`kimi web` 启动的本地服务暴露一组程序化接口:REST API(`/api/v1`,另有 `/api/v2/sessions` 和 `/api/v2/mcp`)。本页是该接口的协议参考。如何启动服务及其命令行选项见 [kimi 命令](./kimi-command.md#kimi-web) 参考;端到端的上手流程见下文「[用 API 驱动一个会话](#用-api-驱动一个会话)」。 -本页是一份经过整理、面向人阅读的参考:下文逐一记录每个端点的参数、请求体与响应结构。每个端点精确的机器可读 schema 以服务的在线规范文档为准:`GET /openapi.json`(OpenAPI)与 `GET /asyncapi.json`(AsyncAPI),两者都由服务运行时实际执行的校验 schema 生成。两者都需要鉴权;当本页与在线规范不一致时,以在线规范为准。 +本页是一份经过整理、面向人阅读的参考:下文逐一记录每个端点的参数、请求体与响应结构。每个端点精确的机器可读 schema 以服务的在线规范文档为准:`GET /openapi.json`(OpenAPI),由服务运行时实际执行的校验 schema 生成。该文档需要鉴权;当本页与在线规范不一致时,以在线规范为准。 ::: warning 注意 -本页描述的 REST 与 WebSocket API 为实验性特性:不保证接口稳定性,端点、字段与事件类型可能随任何版本更改。集成时请以你所用版本服务的 `/openapi.json` 与 `/asyncapi.json` 文档为准。 +本页描述的 REST API 为实验性特性:不保证接口稳定性,端点与字段可能随任何版本更改。集成时请以你所用版本服务的 `/openapi.json` 文档为准。 ::: ## 基础约定 @@ -16,7 +16,7 @@ ### 鉴权 -除以下例外,所有 `/api/*` 路径(含 `/openapi.json` 与 `/asyncapi.json`)都要求 bearer token: +除以下例外,所有 `/api/*` 路径(含 `/openapi.json`)都要求 bearer token: - `OPTIONS` 预检请求 - `GET /api/v1/healthz`(探活) @@ -76,12 +76,12 @@ HTTP 状态码几乎总是 200,业务结果以 `code` 为准。例外情况: 列表端点有两种分页风格: -- **游标式**:`before_id` / `after_id`(互斥)加 `page_size`(1–100),响应为 `{ items, has_more }`。用于会话列表、消息列表、转录等。 +- **游标式**:`before_id` / `after_id`(互斥)加 `page_size`(1–100),响应为 `{ items, has_more }`。用于会话列表等。 - **`page_token`**:不透明令牌(绑定了查询条件的指纹),用于 `POST /api/v1/search` 与 `GET /api/v2/sessions`。翻页途中改变任何查询条件会使令牌失效:v2 返回 `40922`,search 返回 `40001`。`GET /api/v2/sessions` 另提供无状态的 `page` 页码模式作为替代。 ## 用 API 驱动一个会话 -下面用 curl 走一遍最小流程:确认服务状态 → 创建会话 → 订阅事件 → 提交提示词 → 回读历史。示例假设服务跑在默认地址,token 已存入 shell 变量 `TOKEN`。 +下面用 curl 走一遍最小流程:确认服务状态 → 创建会话 → 提交提示词 → 回读会话状态。示例假设服务跑在默认地址,token 已存入 shell 变量 `TOKEN`。 1. 确认服务状态: @@ -102,25 +102,7 @@ curl -s -X POST http://127.0.0.1:58627/api/v1/sessions \ 返回的 `data.id`(形如 `session_...`)就是后续所有请求要用的会话 id。 -3. 连接 WebSocket 并订阅会话事件。任何 WebSocket 客户端都可以;下面是一个零依赖的 Node.js 脚本(Node.js 22+ 内置 `WebSocket` 客户端): - -```js -// subscribe.mjs —— 用法:TOKEN=... node subscribe.mjs session_... -const ws = new WebSocket('ws://127.0.0.1:58627/api/v1/ws', [ - `kimi-code.bearer.${process.env.TOKEN}`, -]); -ws.onmessage = (e) => console.log(e.data); -ws.onopen = () => - ws.send( - JSON.stringify({ - type: 'subscribe', - id: '1', - payload: { session_ids: [process.argv[2]] }, - }), - ); -``` - -4. 提交提示词: +3. 提交提示词: ```sh curl -s -X POST http://127.0.0.1:58627/api/v1/sessions//prompts \ @@ -129,15 +111,15 @@ curl -s -X POST http://127.0.0.1:58627/api/v1/sessions//prompts \ -d '{"content": [{"type": "text", "text": "用一句话介绍这个仓库"}]}' ``` -订阅端会依次看到 `turn.started`(轮次开始)→ `assistant.delta`(流式文本增量)→ 发生工具调用时的 `tool.call.started` / `tool.result` → `turn.ended`(轮次结束)。 - -5. 随时可以用 REST 回读历史消息: +4. 随时可以用 REST 回读会话的实时状态: ```sh curl -s -H "Authorization: Bearer $TOKEN" \ - "http://127.0.0.1:58627/api/v1/sessions//messages?page_size=20" + "http://127.0.0.1:58627/api/v1/sessions//status" ``` +汇总中的 `busy` 表示轮次是否在进行中,同时给出生效模型与上下文用量。 + ## REST 端点 下文按资源分组列出端点。路径里的 `:{action}` 后缀是动作约定——对单个资源 POST 到 `路径:动作` 执行非 CRUD 操作(如会话的 `:fork`、`:archive`)。 @@ -274,7 +256,7 @@ curl -s -H "Authorization: Bearer $TOKEN" \ | 方法与路径 | 说明 | | --- | --- | | `GET /api/v1/config` | 读取全局配置(密钥字段脱敏) | -| `POST /api/v1/config` | 合并式更新配置,并广播 `event.config.changed` | +| `POST /api/v1/config` | 合并式更新配置 | #### `GET /api/v1/config` @@ -310,8 +292,6 @@ curl -s -H "Authorization: Bearer $TOKEN" \ 合并式更新全局配置:请求体中的每个顶层域被深合并进对应域,未出现在请求体中的域保持不动。把 `yolo` 设为 `true` 是 `default_permission_mode: "yolo"` 的简写;被拒绝的补丁(值非法或持久化失败)返回 `40001` 与底层错误信息。 -每一次配置变更——经本端点成功更新、在进程外编辑 `config.toml`,或服务端内部写入(如 OAuth 登录刷新)——都会广播全局 `event.config.changed` 事件。短时间窗内的多次变更会合并为一个事件,其 `changedFields` 携带受影响的域名(camelCase 配置域,例如 `defaultModel`),`config` 携带当前完整的配置投影(与 `GET /api/v1/config` 响应同形状)。 - 请求体是部分配置对象——上述响应域中除 `raw` 外的任意子集,均为可选: | 参数 | 位置 | 类型 | 说明 | @@ -469,7 +449,7 @@ curl -s -H "Authorization: Bearer $TOKEN" \ #### `POST /api/v1/providers/{provider_id}:refresh` -从上游来源重新发现单个供应商的模型元数据,并重写该供应商的别名。模型来源为静态的供应商不经任何网络调用直接报告 `unchanged`。至少一个供应商的别名发生变化时,服务会广播全局 `event.model_catalog.changed` 事件。 +从上游来源重新发现单个供应商的模型元数据,并重写该供应商的别名。模型来源为静态的供应商不经任何网络调用直接报告 `unchanged`。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | @@ -568,7 +548,6 @@ curl -s -H "Authorization: Bearer $TOKEN" \ | `GET /api/v1/sessions/{session_id}/runtime` | 读取 main agent 的运行时绑定 | | `POST /api/v1/sessions/{session_id}/runtime` | 切换 main agent 的运行时绑定 | | `POST /api/v1/sessions/{session_id}/export` | 导出会话与诊断信息(zip 流,不走信封) | -| `GET /api/v1/sessions/{session_id}/snapshot` | 客户端重建用全量快照(含 `as_of_seq` 与 `epoch`) | | `GET /api/v1/sessions/{session_id}/media/{file_id}` | 按文件 id 下载提示词媒体(二进制) | #### session 对象 @@ -589,15 +568,14 @@ curl -s -H "Authorization: Bearer $TOKEN" \ | `last_turn_reason` | string | main agent 最近一次轮次的结果:`completed` / `cancelled` / `failed` | | `last_prompt` | string | 最近一条用户提示词文本(如有) | | `metadata` | object | 自定义元数据;始终携带 `cwd`(会话的工作目录) | -| `agent_config` | object | 投影为 `{ model }`;`model` 在大多数响应中为 `""`,仅由 `GET /api/v1/sessions/{session_id}/snapshot` 填入实时模型 | -| `usage` | object | token 汇总 `{ input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, context_tokens, context_limit?, total_cost_usd?, turn_count? }`;在 snapshot 端点之外全为零 | +| `agent_config` | object | 投影为 `{ model }`;`model` 当前始终为 `""` | +| `usage` | object | token 汇总 `{ input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, context_tokens, context_limit?, total_cost_usd?, turn_count? }`;当前全为零 | | `permission_rules` | array | 会话权限规则;当前始终为 `[]` | | `message_count` | integer | 消息数;当前始终为 `0` | -| `last_seq` | integer | 最后的事件序列号;当前始终为 `0` | #### `POST /api/v1/sessions` -创建会话并返回。目标目录来自 `workspace_id`(已注册的工作区)或 `metadata.cwd`(首次使用时注册该工作区);两者同时提供时必须一致。创建时会广播全局 `event.session.created` 事件。 +创建会话并返回。目标目录来自 `workspace_id`(已注册的工作区)或 `metadata.cwd`(首次使用时注册该工作区);两者同时提供时必须一致。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | @@ -658,7 +636,7 @@ curl -s -H "Authorization: Bearer $TOKEN" \ #### `POST /api/v1/sessions/{session_id}/profile` -更新会话档案:标题、自定义元数据以及 main agent 的配置。在这里设置的标题会成为自定义标题,优先级高于生成的标题;设置标题会广播全局 `session.meta.updated` 事件。 +更新会话档案:标题、自定义元数据以及 main agent 的配置。在这里设置的标题会成为自定义标题,优先级高于生成的标题。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | @@ -687,7 +665,7 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers` #### `POST /api/v1/sessions/{session_id}/title/generate` -通过托管供应商的 `chat_title` 工具根据会话的提示词生成标题并应用,同时广播 `session.meta.updated`。生成需要托管 OAuth 登录和 `auto_session_title` 实验开关;未提供 `force` 时,已有自定义标题或已生成标题的会话会上报为不可用,而不会被覆盖。 +通过托管供应商的 `chat_title` 工具根据会话的提示词生成标题并应用。生成需要托管 OAuth 登录和 `auto_session_title` 实验开关;未提供 `force` 时,已有自定义标题或已生成标题的会话会上报为不可用,而不会被覆盖。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | @@ -706,7 +684,7 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers` #### `POST /api/v1/sessions/{session_id}:fork` -将会话——其转录、Agent 状态与文件——复制到同一工作区中的新会话,并广播 `event.session.created`。当会话中任一 Agent 有进行中的轮次时,fork 会被拒绝。 +将会话——其转录、Agent 状态与文件——复制到同一工作区中的新会话。当会话中任一 Agent 有进行中的轮次时,fork 会被拒绝。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | @@ -719,7 +697,7 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers` #### `POST /api/v1/sessions/{session_id}:compact` -对 main agent 的上下文发起一次手动全量压缩。调用立即返回;进度与完成通过 `compaction.*` WebSocket 事件投递。 +对 main agent 的上下文发起一次手动全量压缩。调用立即返回。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | @@ -757,7 +735,7 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers` #### `POST /api/v1/sessions/{session_id}:archive` -将会话标记为已归档:它从默认会话列表中消失(使用 `include_archive` 或 `archived_only` 时仍会列出),并且服务端广播全局 `event.session.archived` 事件。 +将会话标记为已归档:它从默认会话列表中消失(使用 `include_archive` 或 `archived_only` 时仍会列出)。 成功时,`data` 为 `{ archived: true }`。 @@ -793,7 +771,7 @@ schema 还接受 `agent_config` 内的 `system_prompt`、`tools`、`mcp_servers` | `title` | body | string | 子会话的标题(至少 1 个字符)。默认 `Child: ` | | `metadata` | body | object | 子会话的自定义元数据 | -成功时,`data` 为新会话的 [session 对象](#session-对象),并且服务端广播 `event.session.created`。 +成功时,`data` 为新会话的 [session 对象](#session-对象)。 - `40901`:会话有进行中的轮次,无法 fork @@ -869,18 +847,6 @@ main agent 的实时状态汇总;读取它会在会话为冷态时将其恢复 | `web_log` | body | string | 要包含在归档中的客户端日志文本,最多 256 KB UTF-8 | | `desktop` | body | boolean | 同时包含桌面宿主的日志。默认 `false` | -#### `GET /api/v1/sessions/{session_id}/snapshot` - -为重新同步后重建客户端组装一份原子快照:会话、最近的消息、进行中的轮次、存活的 subagent 以及待处理交互,全部盖上 `as_of_seq` 水位与用于重新订阅的 `epoch`——见 [断线恢复](#断线恢复)。与普通的会话端点不同,内嵌的会话携带实时的 `agent_config.model` 与真实的 `usage` 总计。 - -| 参数 | 位置 | 类型 | 说明 | -| --- | --- | --- | --- | -| `session_id` | path | string | **必填。** 会话 id | - -成功时,`data` 为 `{ as_of_seq, epoch, session, messages, in_flight_turn, subagents?, pending_approvals, pending_questions }`:`session` 为 [session 对象](#session-对象),`messages` 为最新 100 条消息的 `{ items, has_more }`,`in_flight_turn` 为已部分流式输出的轮次(空闲时为 `null`,已知时带 `current_prompt_id`),`subagents` 列出存活的 subagent 任务,`pending_approvals` / `pending_questions` 承载未答复的交互。 - -- `40401`:会话不存在 - #### `GET /api/v1/sessions/{session_id}/media/{file_id}` 按文件 id 下载提示词媒体文件(会话提示词引用的图片或其他附件);尚未提交到会话的 id 会回退到暂存的上传中查找。响应为二进制并支持 `Range`(范围请求返回 206)——共享约定见 [二进制与流式端点](#二进制与流式端点);与那里走信封的端点不同,会话或文件不存在时会返回真正的 404 状态码并携带信封体。 @@ -890,115 +856,9 @@ main agent 的实时状态汇总;读取它会在会话为冷态时将其恢复 | `session_id` | path | string | **必填。** 会话 id | | `file_id` | path | string | **必填。** 媒体文件 id | -### 消息与转录 - -`messages` 端点分页返回 main agent 的扁平化消息历史,`transcript` 端点则提供按 Agent 组织的结构化转录——轮次、任务、交互、附件——即 WebSocket [转录协议](#转录协议) 实时流式推送的内容。历史分页与补漏用这些端点,实时尾部用 WebSocket 订阅。 - -| 方法与路径 | 说明 | -| --- | --- | -| `GET /api/v1/sessions/{session_id}/messages` | 消息分页(`before_id` / `after_id` / `role`) | -| `GET /api/v1/sessions/{session_id}/messages/{message_id}` | 读取单条消息 | -| `GET /api/v1/sessions/{session_id}/transcript` | 按轮次分页的转录(需 `agent_id`);全局状态不分页随响应返回 | -| `GET /api/v1/sessions/{session_id}/transcript/ops` | op 批次补漏(`since_seq`);`complete: false` 表示需要全量刷新 | -| `GET /api/v1/sessions/{session_id}/transcript/user-messages` | 各轮次起始的用户输入,不分页 | -| `GET /api/v1/sessions/{session_id}/transcript/plan` | ExitPlanMode 计划内容、路径与审阅结果 | - -#### `GET /api/v1/sessions/{session_id}/messages` - -分页返回 main agent 的消息历史——与会话快照共享的扁平化上下文转录——最新在前。游标分页遵循 [分页](#分页);读取历史会在会话为冷态时将其恢复。 - -| 参数 | 位置 | 类型 | 说明 | -| --- | --- | --- | --- | -| `session_id` | path | string | **必填。** 会话 id | -| `before_id` | query | string | 只保留早于该消息 id 的消息;与 `after_id` 互斥 | -| `after_id` | query | string | 只保留晚于该消息 id 的消息;与 `before_id` 互斥 | -| `page_size` | query | integer | 1–100。默认 `50` | -| `role` | query | string | 只保留单一角色:`user` / `assistant` / `tool` / `system`。过滤在分页切片之后应用,因此过滤后的一页可能少于 `page_size` 条而 `has_more` 仍为 `true`——持续翻页直到 `has_more` 为 `false` | - -成功时,`data` 为 `{ items, has_more }`,其中每个元素是消息对象 `{ id, session_id, role, content, created_at, prompt_id?, parent_message_id?, metadata? }`;`content` 是按 [提示词](#提示词) 中说明的线上格式组成的内容块数组(`text`、`tool_use`、`tool_result`、`image`、`video`、`file`、`thinking`)。 - -- `40001`:校验失败——例如 `before_id` 与 `after_id` 同用 -- `40401`:会话不存在 - -#### `GET /api/v1/sessions/{session_id}/messages/{message_id}` - -按 id 从同一历史中读取单条消息。 - -| 参数 | 位置 | 类型 | 说明 | -| --- | --- | --- | --- | -| `session_id` | path | string | **必填。** 会话 id | -| `message_id` | path | string | **必填。** 消息 id | - -成功时,`data` 为上文 `GET /api/v1/sessions/{session_id}/messages` 中说明的元素形态的消息对象。 - -- `40401`:会话不存在 -- `40403`:该会话中不存在此 id 的消息 - -#### `GET /api/v1/sessions/{session_id}/transcript` - -返回某个 Agent 的结构化转录中的一页:轮次(含其步骤与帧)以及轮次之间的标记与任务引用。活跃会话从内存存储应答(先回填所请求 Agent 的持久化历史);冷会话则从持久化的线上记录重建 Agent。这是转录能力的历史半边——实时流式半边是 [转录协议](#转录协议) 订阅。 - -| 参数 | 位置 | 类型 | 说明 | -| --- | --- | --- | --- | -| `session_id` | path | string | **必填。** 会话 id | -| `agent_id` | query | string | **必填。** 要读取其转录的 Agent;必须是纯文本形式的 agent id(字母、数字、`.`、`_`、`-`——不含路径分隔符) | -| `before_turn` | query | string | 只保留早于该轮次 id 的轮次;与 `after_turn` 互斥 | -| `after_turn` | query | string | 只保留晚于该轮次 id 的轮次;与 `before_turn` 互斥 | -| `page_size` | query | integer | 1–100 个轮次。默认 `20` | - -分页单位是轮次:不带游标时返回最新的一页,`has_more` 表示还有更早的轮次。成功时,`data` 为 `{ agent_id, items, has_more, tasks, interactions, attachments, todos, meta, agents, pending_interactions, seq? }`——`items` 是本次分页的轮次切片,`tasks` / `interactions` / `attachments` / `todos` / `meta` / `agents` / `pending_interactions` 是不分页、随每次响应一起返回的全局 Agent 状态,`seq` 是该 Agent 用于恢复流的 op 批次水位(仅活跃会话)。 - -- `40001`:校验失败——`before_turn` 与 `after_turn` 同用,或 `agent_id` 不是纯文本形式 -- `40401`:会话不存在 - -#### `GET /api/v1/sessions/{session_id}/transcript/ops` - -从服务端的 op 日志提供点对点的补漏:某个 Agent 的 `seq > since_seq` 的已记录 op 批次,最旧在前。它是 [转录协议](#转录协议) 中 `transcript_since` 恢复游标的 REST 对应物,共享同一份有界日志,因此适用相同的回退规则。 - -| 参数 | 位置 | 类型 | 说明 | -| --- | --- | --- | --- | -| `session_id` | path | string | **必填。** 会话 id | -| `agent_id` | query | string | **必填。** Agent id(纯文本形式,约束与转录端点相同) | -| `since_seq` | query | integer | **必填。** 调用方已应用的最后一个 op 批次 seq,最小为 `0`;返回其之后的批次 | - -成功时,`data` 为 `{ agent_id, batches, latest_seq, complete }`,每个批次为 `{ seq, ops }`。`complete: true` 表示直到 `latest_seq` 的每个批次都在;`complete: false` 表示日志已不再覆盖到 `since_seq`(或会话根本不是活跃状态),调用方必须回退为一次完整的 `GET .../transcript` 刷新。 - -- `40001`:校验失败 -- `40401`:会话不存在 - -#### `GET /api/v1/sessions/{session_id}/transcript/user-messages` - -列出会话中每个开启轮次的输入,按 Agent 分组且不分页:真实用户文本、以斜杠命令形式使用的 Skill 与插件命令、以及 cron 提示词——可通过 `origin` 区分——另有仅含附件的提示词,其 `prompt` 投影为空。所列消息引用的附件实体会随响应一起返回(仅元数据,绝不包含字节内容)。 - -| 参数 | 位置 | 类型 | 说明 | -| --- | --- | --- | --- | -| `session_id` | path | string | **必填。** 会话 id | -| `agent_id` | query | string | 只读取一个 Agent(纯文本 id)。默认读取所有在册 Agent | - -成功时,`data` 为 `{ agents }`,每个条目为 `{ agent_id, messages, attachments }`;消息为 `{ turn_id, ordinal, state, origin, prompt, attachment_ids?, started_at? }`,其中 `state` 为轮次状态(`queued` / `running` / `completed` / `failed` / `cancelled`)。 - -- `40001`:校验失败——`agent_id` 不是纯文本形式 -- `40401`:会话不存在 - -#### `GET /api/v1/sessions/{session_id}/transcript/plan` - -按时间线顺序读取某个 Agent 的 `ExitPlanMode` 工具调用的计划信息——计划内容、计划文件路径、提供的选项以及审阅结果。内容投影自第一个可用的事实来源:关联的审批交互(交互式审阅)、实时工具帧的展示(auto 模式),或工具结果的输出文本;每个条目在 `source` 中记录了具体来源。 - -| 参数 | 位置 | 类型 | 说明 | -| --- | --- | --- | --- | -| `session_id` | path | string | **必填。** 会话 id | -| `agent_id` | query | string | **必填。** Agent id(纯文本形式) | -| `tool_call_id` | query | string | 将读取范围限定到单次 `ExitPlanMode` 调用;不提供时列出所有可恢复计划内容的调用 | - -成功时,`data` 为 `{ agent_id, plans }`,每个计划为 `{ tool_call_id, turn_id, source, plan, path?, options?, review? }`:`source` 为 `interaction` / `display` / `output`,`options` 是审阅选项,形如 `{ label, description? }`,`review`(仅交互式审阅时存在)为 `{ state, selected_option?, feedback? }`,其中 `state` 为 `pending` / `approved` / `rejected` / `cancelled` 之一。 - -- `40001`:校验失败 -- `40401`:会话不存在 -- `40416`:提供了 `tool_call_id`,但不存在该 id 的 `ExitPlanMode` 调用 - ### 提示词 -提示词是一次用户输入的单位:提交一条提示词会把它排入会话的 main agent(或指定 Agent)的队列,排队中的提示词可以插入进行中的轮次,运行中的提示词可以中止。轮次进度本身通过 WebSocket [事件](#事件) 流式推送,不经过这些端点。 +提示词是一次用户输入的单位:提交一条提示词会把它排入会话的 main agent(或指定 Agent)的队列,排队中的提示词可以插入进行中的轮次,运行中的提示词可以中止。 | 方法与路径 | 说明 | | --- | --- | @@ -1108,7 +968,7 @@ schema 还接受共享消息格式中的 `tool_use`、`tool_result` 和 `thinkin ### 审批与提问 -审批与提问是会话的两类待处理交互:审批是为工具调用请求许可,提问是请求带标签选项的结构化输入。这些端点用于列出和答复它们;新的请求通过 WebSocket 以 `event.approval.requested` 与 `event.question.requested` 到达。 +审批与提问是会话的两类待处理交互:审批是为工具调用请求许可,提问是请求带标签选项的结构化输入。这些端点用于列出和答复它们。 | 方法与路径 | 说明 | | --- | --- | @@ -1483,7 +1343,7 @@ schema 还接受共享消息格式中的 `tool_use`、`tool_result` 和 `thinkin ### 终端 -PTY 终端接口;仅在 loopback 绑定时挂载(非 loopback 绑定会跳过它们,除非传入 `--allow-remote-terminals`)。终端的输入、输出与尺寸调整经 WebSocket 的 `terminal_*` 帧传输——REST 侧只管理终端生命周期。 +PTY 终端接口;仅在 loopback 绑定时挂载(非 loopback 绑定会跳过它们,除非传入 `--allow-remote-terminals`)。REST 侧只管理终端生命周期。 | 方法与路径 | 说明 | | --- | --- | @@ -1500,7 +1360,7 @@ PTY 终端接口;仅在 loopback 绑定时挂载(非 loopback 绑定会跳 | --- | --- | --- | --- | | `session_id` | path | string | **必填。** 会话 id | -成功时 `data` 为 `{ items }`,每项是一个终端对象 `{ id, session_id, cwd, shell, cols, rows, status, created_at, exited_at?, exit_code? }`:`status` 为 `running` / `exited`;已退出的终端携带 `exited_at` 与 `exit_code`(进程未报告退出码时为 `null`,例如因信号终止)。回滚缓冲不属于该对象——输出经 WebSocket 回放与流式推送。 +成功时 `data` 为 `{ items }`,每项是一个终端对象 `{ id, session_id, cwd, shell, cols, rows, status, created_at, exited_at?, exit_code? }`:`status` 为 `running` / `exited`;已退出的终端携带 `exited_at` 与 `exit_code`(进程未报告退出码时为 `null`,例如因信号终止)。回滚缓冲不属于该对象。 - `40401`:会话不存在 @@ -1569,7 +1429,7 @@ PTY 终端接口;仅在 loopback 绑定时挂载(非 loopback 绑定会跳 #### workspace 对象 -所有返回工作区的端点都使用此传输结构。注册与重命名会广播全局事件 `event.workspace.created` / `event.workspace.updated`。 +所有返回工作区的端点都使用此传输结构。 | 字段 | 类型 | 说明 | | --- | --- | --- | @@ -1588,7 +1448,7 @@ PTY 终端接口;仅在 loopback 绑定时挂载(非 loopback 绑定会跳 #### `POST /api/v1/workspaces` -注册工作区并返回它。注册按根路径幂等:重复注册同一根路径会返回已存在的工作区,仅刷新 `last_opened_at`(保留已存名称),并广播 `event.workspace.updated` 而非 `event.workspace.created`。 +注册工作区并返回它。注册按根路径幂等:重复注册同一根路径会返回已存在的工作区,仅刷新 `last_opened_at`(保留已存名称)。 | 参数 | 位置 | 类型 | 说明 | | --- | --- | --- | --- | @@ -2335,68 +2195,6 @@ locator 寻址的目录(脱敏配置),外加对每个 OAuth 候选的批 - `40408`:(`:begin` / `:reset`)locator 未匹配到任何条目 - `40929`:OAuth 流程本身失败 -## WebSocket 协议 - -### 建立连接 - -唯一端点是 `ws://:/api/v1/ws`;鉴权在升级请求时完成(见上文 [鉴权](#鉴权))。连接建立后服务端立即发送 `server_hello`: - -```json -{ - "type": "server_hello", - "timestamp": "2026-01-01T00:00:00.000Z", - "payload": { - "ws_connection_id": "conn_01JZX4...", - "protocol_version": 2, - "max_event_buffer_size": 1000, - "capabilities": { "event_batching": false, "compression": false } - } -} -``` - -注意服务端不发送心跳,也不会主动断开空闲连接——保活与重连由客户端自己负责。 - -### 控制帧 - -客户端发送 JSON 帧 `{ "type", "id"?, "payload" }`;每个请求帧都会收到应答 `{ "type": "ack", "id", "code", "msg", "payload" }`,`code` 为 `0` 表示成功。 - -| 帧 | payload | 说明 | -| --- | --- | --- | -| `subscribe` | `{ session_ids, cursors?, agent_filter? }` | 订阅会话事件;带 `cursors`(每会话 `{seq, epoch}`)时回放错过的持久事件 | -| `unsubscribe` | `{ session_ids }` | 取消会话订阅 | -| `subscribe_v2` | `{ session_id, transcript, transcript_since? }` | 订阅转录流(唯一的转录订阅通道),`transcript` 按 agent 指定粒度 | -| `unsubscribe_v2` | `{ session_id, agent_ids? }` | 退订转录流;省略 `agent_ids` 表示整个会话 | -| `client_hello` | `{ client_id }` | 握手帧,其余字段为遗留兼容 | - -### 事件 - -事件帧形状为 `{ "type", "seq", "epoch"?, "volatile"?, "offset"?, "session_id"?, "timestamp", "payload" }`,`type` 即事件类型。按投递范围分两类: - -- **全局事件**:发送到每个已建立连接,无需订阅——`session.meta.updated`、`event.session.created`、`event.session.archived`、`event.session.work_changed`、`event.session.status_changed`、`event.workspace.*`、`event.config.*`、`event.model_catalog.*`。 -- **会话事件**:只发给订阅了该会话的连接,受 `agent_filter` 过滤。主要事件族: - -| 事件族 | 主要事件 | -| --- | --- | -| 轮次 | `turn.started`、`turn.ended`、`turn.step.started` / `completed` / `interrupted` / `retrying` | -| 流式文本 | `assistant.delta`、`thinking.delta`(带 `offset` 用于对齐) | -| 工具调用 | `tool.call.started`、`tool.call.delta`、`tool.progress`、`tool.result` | -| 交互 | `event.approval.requested` / `resolved`、`event.question.requested` / `answered` / `dismissed` | -| subagent | `subagent.spawned` / `started` / `suspended` / `completed` / `failed` | -| 后台 | `task.started` / `terminated`、`shell.started` / `output` / `completed` | -| 其他 | `compaction.*`、`skill.activated`、`goal.updated`、`prompt.*`、`error`、`warning` | - -有三个全局生命周期事件可以让跨工作区概览免掉逐工作区轮询。`event.session.archived` 在在线归档与冷归档两条路径上都会发出;其事件帧 `session_id` 是全局水位 `__global__`,真实会话 id 在 payload 里:`{ "type": "event.session.archived", "workspace_id": "wd_...", "sessionId": "session_..." }`(payload 字段为 `workspace_id` / `sessionId`)。`event.workspace.created` / `updated` 携带完整工作区对象(`{ id, root, name, created_at, last_opened_at, session_count }`——会话创建触碰工作区时也会发 `updated`),`event.workspace.deleted` 携带 `{ "workspace_id", "root" }`。这些事件只覆盖本服务进程内的变更;其他进程(例如写同一 home 目录的 CLI)的变更要等索引 reconcile(约一分钟)才可见,因此概览客户端应保留低频兜底轮询。目前没有会话删除事件。 - -事件另分持久与易失两种:持久事件带严格递增的 `seq`,落盘并可回放;易失事件(各 `*.delta`、`tool.progress`、`shell.*` 等)标 `volatile: true`,不回放。消费易失文本流时用 `offset`(该轮次内的累计字符偏移)与本地已累积文本比对:小于本地长度说明是重复帧,大于说明有缺漏、需走快照恢复。 - -### 断线恢复 - -重连后在 `subscribe` 的 `cursors` 里带上每个会话最后应用事件的 `{seq, epoch}`,服务端会回放缺口;落后超过缓冲(1000 条)或游标失效时改为收到 `resync_required`。此时调用 `GET /api/v1/sessions/{session_id}/snapshot` 拿全量快照(含 `as_of_seq` 与 `epoch`),再以新游标重新订阅。 - -### 转录协议 - -`subscribe_v2` 的 `transcript` 按 agent 指定粒度:`off` / `turn` / `block` / `delta`(键 `"*"` 表示默认粒度),粒度越高推送越细。粒度非 `off` 的 agent 走两帧推送:`transcript.reset`(基线快照,历史经 REST 分页回读)和 `transcript.ops`(增量批次,带每个 agent 连续递增的 `seq`);该 agent 的旧式事件在同一连接上被抑制,改由转录帧承载。断线时用 `transcript_since` 续传;服务端批次日志无法覆盖缺口时(REST 补漏返回 `complete: false`)需全量刷新。REST 侧对应 `GET .../transcript`(按轮次分页)与 `GET .../transcript/ops?since_seq=`(批次补漏)。 - ## 二进制与流式端点 以下端点返回二进制流而非 JSON 载荷,各端点的 HTTP 能力并不相同: diff --git a/flake.nix b/flake.nix index 3da921b1d4d..1d09841e3bf 100644 --- a/flake.nix +++ b/flake.nix @@ -75,7 +75,6 @@ ./packages/pi-tui ./packages/remote-control ./packages/telemetry - ./packages/transcript ./packages/tree-sitter-bash ./apps/kimi-code ./apps/vscode @@ -100,7 +99,6 @@ "@moonshot-ai/pi-tui" "@moonshot-ai/remote-control" "@moonshot-ai/kimi-telemetry" - "@moonshot-ai/transcript" "@moonshot-ai/tree-sitter-bash" "@moonshot-ai/kimi-code" "kimi-code" diff --git a/packages/agent-core-v2/src/human/agent/turn.ts b/packages/agent-core-v2/src/human/agent/turn.ts index f42735399ed..7fdff9b8d44 100644 --- a/packages/agent-core-v2/src/human/agent/turn.ts +++ b/packages/agent-core-v2/src/human/agent/turn.ts @@ -227,7 +227,6 @@ export interface TurnMachineContext { appliedRecoveries: LlmRecoveryRecord[]; recoveryMessages?: readonly Message[]; paused: boolean; - lastError?: LlmRemoteErrorMessage; outcome?: 'done' | 'failed' | 'aborted'; error?: unknown; } diff --git a/packages/kap-server/package.json b/packages/kap-server/package.json index b30f44d9abe..4a197c14f07 100644 --- a/packages/kap-server/package.json +++ b/packages/kap-server/package.json @@ -40,7 +40,6 @@ "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/minidb": "workspace:*", "@moonshot-ai/remote-control": "workspace:^", - "@moonshot-ai/transcript": "workspace:^", "bcryptjs": "^2.4.3", "fastify": "^5.1.0", "pino": "^9.5.0", diff --git a/packages/kap-server/src/middleware/auth.ts b/packages/kap-server/src/middleware/auth.ts index 0bb9ea42f84..c31a58678db 100644 --- a/packages/kap-server/src/middleware/auth.ts +++ b/packages/kap-server/src/middleware/auth.ts @@ -41,7 +41,7 @@ function defaultIsBypassed(req: FastifyRequest): boolean { return true; } const isApi = path.startsWith('/api/'); - const isMeta = path === '/openapi.json' || path === '/asyncapi.json'; + const isMeta = path === '/openapi.json'; return !isApi && !isMeta; } diff --git a/packages/kap-server/src/protocol/asyncapi.ts b/packages/kap-server/src/protocol/asyncapi.ts deleted file mode 100644 index 5b06b82df21..00000000000 --- a/packages/kap-server/src/protocol/asyncapi.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { z } from 'zod'; - -import { wsOperations, type WsOperationDefinition } from './ws-control'; - -const ASYNCAPI_VERSION = '3.1.0'; -const DEFAULT_TITLE = 'Kimi Code WebSocket API'; -const DEFAULT_VERSION = '0.1.0'; -const DEFAULT_SERVER_HOST = 'localhost'; -const DEFAULT_WS_PATH = '/api/v1/ws'; -const CHANNEL_ID = 'kimiCodeWebSocket'; -const ASYNCAPI_OPERATIONS: readonly WsOperationDefinition[] = wsOperations; - -export interface AsyncApiDocumentOptions { - readonly title?: string; - readonly version?: string; - readonly serverHost?: string; - readonly serverProtocol?: 'ws' | 'wss'; - readonly wsPath?: string; -} - -export function createAsyncApiDocument( - options: AsyncApiDocumentOptions = {}, -): Record { - const title = options.title ?? DEFAULT_TITLE; - const version = options.version ?? DEFAULT_VERSION; - const serverHost = options.serverHost ?? DEFAULT_SERVER_HOST; - const serverProtocol = options.serverProtocol ?? 'ws'; - const wsPath = options.wsPath ?? DEFAULT_WS_PATH; - const messages = buildMessages(); - const channelMessages = Object.fromEntries( - Object.keys(messages).map((id) => [id, { $ref: `#/components/messages/${id}` }]), - ); - - return { - asyncapi: ASYNCAPI_VERSION, - info: { - title, - version, - description: - 'WebSocket protocol for Kimi Code daemon control frames, acknowledgements, system frames, and session event streaming.', - }, - defaultContentType: 'application/json', - servers: { - local: { - host: serverHost, - protocol: serverProtocol, - pathname: wsPath, - description: 'Kimi Code daemon WebSocket endpoint.', - }, - }, - channels: { - [CHANNEL_ID]: { - address: wsPath, - servers: [{ $ref: '#/servers/local' }], - messages: channelMessages, - }, - }, - operations: { - receiveClientMessages: { - action: 'receive', - channel: { $ref: `#/channels/${CHANNEL_ID}` }, - messages: operationMessageRefs('client_to_server'), - }, - sendServerMessages: { - action: 'send', - channel: { $ref: `#/channels/${CHANNEL_ID}` }, - messages: [ - ...operationMessageRefs('server_to_client'), - ...ackMessageRefs(), - ], - }, - }, - components: { - messages, - }, - }; -} - -function buildMessages(): Record { - const messages: Record = {}; - for (const operation of ASYNCAPI_OPERATIONS) { - const id = messageId(operation.type); - messages[id] = asyncApiMessage(operation.type, operation.description, operation.messageSchema); - if (operation.ackSchema !== undefined) { - const ackId = `${id}_ack`; - messages[ackId] = asyncApiMessage( - `${operation.type}.ack`, - `Acknowledgement for ${operation.type}.`, - operation.ackSchema, - ); - } - } - return messages; -} - -function operationMessageRefs( - direction: WsOperationDefinition['direction'], -): Array<{ $ref: string }> { - return ASYNCAPI_OPERATIONS - .filter((operation) => operation.direction === direction) - .map((operation) => ({ $ref: `#/components/messages/${messageId(operation.type)}` })); -} - -function ackMessageRefs(): Array<{ $ref: string }> { - return ASYNCAPI_OPERATIONS - .filter((operation) => operation.ackSchema !== undefined) - .map((operation) => ({ $ref: `#/components/messages/${messageId(operation.type)}_ack` })); -} - -function asyncApiMessage( - name: string, - summary: string, - schema: z.ZodTypeAny, -): Record { - return { - name, - title: titleFromName(name), - summary, - contentType: 'application/json', - payload: jsonSchema(schema), - }; -} - -function jsonSchema(schema: z.ZodTypeAny): Record { - const converted = z.toJSONSchema(schema, { - target: 'draft-7', - io: 'input', - unrepresentable: 'any', - }) as Record; - delete converted['$schema']; - return converted; -} - -function messageId(type: string): string { - return type.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); -} - -function titleFromName(name: string): string { - return name - .split(/[^A-Za-z0-9]+/) - .filter((part) => part.length > 0) - .map((part) => `${part[0]!.toUpperCase()}${part.slice(1)}`) - .join(' '); -} diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts deleted file mode 100644 index 4a914b0c82c..00000000000 --- a/packages/kap-server/src/protocol/events-zod.ts +++ /dev/null @@ -1,1112 +0,0 @@ -import { z } from 'zod'; - -import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; -import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; -import type { - BundledSkillActivation, - CompactionSummaryOrigin, - CronJobOrigin, - CronMissedOrigin, - HookResultOrigin, - InjectionOrigin, - PluginCommandOrigin, - RetryOrigin, - ShellCommandOrigin, - SkillActivationOrigin, - SkillSource, - SystemTriggerOrigin, - TaskOrigin, - UserPromptOrigin, -} from '@moonshot-ai/agent-core-v2/agent/contextMemory/types'; -import { messageContentSchema } from './message'; -import type { HookResultPayload } from '@moonshot-ai/agent-core-v2/features/externalHooks/agent/agentExternalHooksService'; -import type { - CompactionBlockedPayload, - CompactionCompletedPayload, - CompactionStartedPayload, -} from '@moonshot-ai/agent-core-v2/agent/fullCompaction/compactionOps'; -import type { CompactionResult } from '@moonshot-ai/agent-core-v2/agent/fullCompaction/types'; -import type { - GoalActor, - GoalBudgetLimits, - GoalBudgetReport, - GoalChange, - GoalChangeKind, - GoalChangeStats, - GoalSnapshot, - GoalStatus, - GoalToolResult, -} from '@moonshot-ai/agent-core-v2'; -import type { - AssistantDeltaPayload, - ThinkingDeltaPayload, - ToolCallDeltaPayload, - TurnStepCompletedPayload, - TurnStepInterruptedPayload, - TurnStepStartedPayload, -} from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; -import type { - McpServerStatusEventPayload, - McpServerStatusPayload, - ToolListUpdatedPayload, - ToolListUpdatedReason, -} from '@moonshot-ai/agent-core-v2/agent/mcp/mcpEvents'; -import type { McpOAuthAuthorizationUrlUpdateData } from '@moonshot-ai/agent-core-v2/agent/mcp/tools/auth'; -import type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; -import type { WarningEvent } from '@moonshot-ai/agent-core-v2/agent/profile/profileService'; -import type { PluginCommandActivatedPayload } from '@moonshot-ai/agent-core-v2/agent/pluginCommand/pluginCommand'; -import type { - ShellCompletedPayload, - ShellOutputPayload, - ShellStartedPayload, -} from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommandService'; - -import type { TurnStepRetryingPayload } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; -import type { AgentTaskStatus } from '@moonshot-ai/agent-core-v2/agent/task/types'; -import type { - ToolCallStartedPayload, - ToolProgressPayload, - ToolResultEventPayload, -} from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents'; -import type { UsageStatus } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; -import type { FinishReason } from '@moonshot-ai/agent-core-v2/human/llm/finish-reason'; -import type { TokenUsage } from '@moonshot-ai/agent-core-v2/human/llm/usage'; -import type { - SubagentCompletedPayload, - SubagentFailedPayload, - SubagentSpawnedPayload, - SubagentStartedPayload, -} from '@moonshot-ai/agent-core-v2/session/subagent/mirrorAgentRun'; -import type { SubagentSuspendedPayload } from '@moonshot-ai/agent-core-v2/features/swarm/session/sessionSwarmService'; -import type { ToolUpdate } from '@moonshot-ai/agent-core-v2/tool/toolContract'; - -import { ToolInputDisplaySchema } from './display'; -import { configResponseSchema } from './rest-config'; -import { sessionPendingInteractionSchema, sessionSchema } from './session'; -import { workspaceSchema } from './workspace'; - -export const tokenUsageSchema = z.object({ - inputOther: z.number(), - output: z.number(), - inputCacheRead: z.number(), - inputCacheCreation: z.number(), -}) satisfies z.ZodType; - -export const finishReasonSchema = z.enum([ - 'completed', - 'tool_calls', - 'truncated', - 'filtered', - 'paused', - 'other', -]) satisfies z.ZodType; - -export const usageStatusSchema = z.object({ - byModel: z.record(z.string(), tokenUsageSchema).optional(), - currentTurn: tokenUsageSchema.optional(), - total: tokenUsageSchema.optional(), -}) satisfies z.ZodType; - -export const permissionModeSchema = z.enum(['manual', 'yolo', 'auto']) satisfies z.ZodType; - -export const skillSourceSchema = z.enum(['project', 'user', 'extra', 'builtin']) satisfies z.ZodType; - -export const bundledSkillActivationSchema = z.object({ - activationId: z.string(), - skillName: z.string(), - skillArgs: z.string().optional(), - skillType: z.string().optional(), - skillPath: z.string().optional(), - skillSource: skillSourceSchema.optional(), -}) satisfies z.ZodType; - -export const userPromptOriginSchema = z.object({ - kind: z.literal('user'), - skillActivations: z.array(bundledSkillActivationSchema).optional(), -}) satisfies z.ZodType; - -export const skillActivationOriginSchema = z.object({ - kind: z.literal('skill_activation'), - activationId: z.string(), - skillName: z.string(), - skillArgs: z.string().optional(), - trigger: z.enum(['user-slash', 'model-tool', 'nested-skill']), - skillType: z.string().optional(), - skillPath: z.string().optional(), - skillSource: skillSourceSchema.optional(), -}) satisfies z.ZodType; - -export const pluginCommandOriginSchema = z.object({ - kind: z.literal('plugin_command'), - activationId: z.string(), - pluginId: z.string(), - commandName: z.string(), - commandArgs: z.string().optional(), - trigger: z.literal('user-slash'), -}) satisfies z.ZodType; - -export const injectionOriginSchema = z.object({ - kind: z.literal('injection'), - variant: z.string(), -}) satisfies z.ZodType; - -export const shellCommandOriginSchema = z.object({ - kind: z.literal('shell_command'), - phase: z.enum(['input', 'output']), - isError: z.boolean().optional(), -}) satisfies z.ZodType; - -export const compactionSummaryOriginSchema = z.object({ - kind: z.literal('compaction_summary'), -}) satisfies z.ZodType; - -export const systemTriggerOriginSchema = z.object({ - kind: z.literal('system_trigger'), - name: z.string(), -}) satisfies z.ZodType; - -export const taskLifecycleStatusSchema = z.enum([ - 'running', - 'completed', - 'failed', - 'timed_out', - 'killed', - 'lost', -]) satisfies z.ZodType; - -export const taskOriginSchema = z.object({ - kind: z.literal('task'), - taskId: z.string(), - status: taskLifecycleStatusSchema, - notificationId: z.string(), -}) satisfies z.ZodType; - -export const backgroundTaskOriginSchema = z.object({ - kind: z.literal('background_task'), - taskId: z.string(), - status: taskLifecycleStatusSchema, - notificationId: z.string(), -}); - -export const cronJobOriginSchema = z.object({ - kind: z.literal('cron_job'), - jobId: z.string(), - cron: z.string(), - recurring: z.boolean(), - coalescedCount: z.number(), - stale: z.boolean(), -}) satisfies z.ZodType; - -export const cronMissedOriginSchema = z.object({ - kind: z.literal('cron_missed'), - count: z.number(), -}) satisfies z.ZodType; - -export const hookResultOriginSchema = z.object({ - kind: z.literal('hook_result'), - event: z.string(), - blocked: z.boolean().optional(), -}) satisfies z.ZodType; - -export const retryOriginSchema = z.object({ - kind: z.literal('retry'), - trigger: z.string().optional(), -}) satisfies z.ZodType; - -export const promptOriginSchema = z.discriminatedUnion('kind', [ - userPromptOriginSchema, - skillActivationOriginSchema, - pluginCommandOriginSchema, - injectionOriginSchema, - shellCommandOriginSchema, - compactionSummaryOriginSchema, - systemTriggerOriginSchema, - taskOriginSchema, - backgroundTaskOriginSchema, - cronJobOriginSchema, - cronMissedOriginSchema, - hookResultOriginSchema, - retryOriginSchema, -]); - -export const goalStatusSchema = z.enum(['active', 'paused', 'blocked', 'complete']) satisfies z.ZodType; - -export const goalActorSchema = z.enum(['user', 'model', 'runtime', 'system']) satisfies z.ZodType; - -export const goalBudgetLimitsSchema = z.object({ - tokenBudget: z.number().optional(), - turnBudget: z.number().optional(), - wallClockBudgetMs: z.number().optional(), -}) satisfies z.ZodType; - -export const goalBudgetReportSchema = z.object({ - tokenBudget: z.number().nullable(), - turnBudget: z.number().nullable(), - wallClockBudgetMs: z.number().nullable(), - remainingTokens: z.number().nullable(), - remainingTurns: z.number().nullable(), - remainingWallClockMs: z.number().nullable(), - tokenBudgetReached: z.boolean(), - turnBudgetReached: z.boolean(), - wallClockBudgetReached: z.boolean(), - overBudget: z.boolean(), -}) satisfies z.ZodType; - -export const goalSnapshotSchema = z.object({ - goalId: z.string(), - objective: z.string(), - completionCriterion: z.string().optional(), - status: goalStatusSchema, - turnsUsed: z.number(), - tokensUsed: z.number(), - wallClockMs: z.number(), - budget: goalBudgetReportSchema, - terminalReason: z.string().optional(), -}) satisfies z.ZodType; - -export const goalToolResultSchema = z.object({ - goal: goalSnapshotSchema.nullable(), -}) satisfies z.ZodType; - -export const goalChangeStatsSchema = z.object({ - turnsUsed: z.number(), - tokensUsed: z.number(), - wallClockMs: z.number(), -}) satisfies z.ZodType; - -export const goalChangeKindSchema = z.enum(['lifecycle', 'completion']) satisfies z.ZodType; - -export const goalChangeSchema = z.object({ - kind: goalChangeKindSchema, - status: goalStatusSchema.optional(), - reason: z.string().optional(), - stats: goalChangeStatsSchema.optional(), - actor: goalActorSchema.optional(), -}) satisfies z.ZodType; - -export const kimiErrorCodeSchema = z.enum([ - 'config.invalid', - 'session.not_found', - 'session.already_exists', - 'session.id_invalid', - 'session.id_required', - 'session.id_empty', - 'session.title_empty', - 'session.state_not_found', - 'session.state_invalid', - 'session.fork_active_turn', - 'session.undo_unavailable', - 'session.export_not_found', - 'session.export_missing_version', - 'session.export_output_conflict', - 'session.export_too_large', - 'session.closed', - 'session.permission_mode_invalid', - 'session.thinking_empty', - 'session.model_empty', - 'session.plan_mode_invalid', - 'session.approval_handler_error', - 'session.question_handler_error', - 'session.init_failed', - 'agent.not_found', - 'agent.already_exists', - 'agent.already_running', - 'agent.not_a_subagent', - 'agent.not_owned', - 'agent.type_not_allowed', - 'agent.max_tokens_exceeded', - 'activity.agent_busy', - 'activity.cancelling', - 'activity.disposing', - 'activity.disposed', - 'activity.initializing', - 'activity.session_rejected', - 'turn.agent_busy', - 'goal.already_exists', - 'goal.not_found', - 'goal.objective_empty', - 'goal.objective_too_long', - 'goal.status_invalid', - 'goal.metadata_reserved', - 'goal.not_resumable', - 'goal.unsupported_agent', - 'model.not_configured', - 'model.config_invalid', - 'profile.thinking_alias_conflict', - 'model.not_found', - 'auth.login_required', - 'auth.provisioning_required', - 'auth.token_missing', - 'auth.token_unauthorized', - 'auth.model_not_resolved', - 'context.overflow', - 'loop.max_steps_exceeded', - 'provider.api_error', - 'provider.filtered', - 'provider.rate_limit', - 'provider.auth_error', - 'provider.connection_error', - 'provider.overloaded', - 'provider.not_found', - 'skill.not_found', - 'skill.type_unsupported', - 'skill.name_empty', - 'skill.parse_failed', - 'skill.nested_too_deep', - 'records.write_failed', - 'compaction.failed', - 'compaction.unable', - 'task.task_id_empty', - 'task.limit_exceeded', - 'usage.turn_id_conflict', - 'mcp.server_not_found', - 'mcp.server_disabled', - 'mcp.startup_failed', - 'mcp.tool_name_collision', - 'mcp.oauth_failed', - 'message.not_found', - 'plugin.not_found', - 'plugin.load_failed', - 'request.invalid', - 'request.work_dir_required', - 'request.prompt_input_empty', - 'prompt.id_conflict', - 'prompt.not_found', - 'prompt.already_completed', - 'session.busy', - 'shell.git_bash_not_found', - 'workspace.not_found', - 'terminal.not_found', - 'file.not_found', - 'file.too_large', - 'fs.path_not_found', - 'fs.permission_denied', - 'fs.path_escapes', - 'fs.is_directory', - 'fs.is_binary', - 'fs.too_large', - 'fs.already_exists', - 'fs.too_many_results', - 'fs.grep_timeout', - 'fs.git_unavailable', - 'wire.migration_missing', - 'storage.permission_denied', - 'storage.disk_full', - 'cron.expression_invalid', - 'web.invalid_url', - 'web.private_address', - 'web.fetch_failed', - 'validation.failed', - 'not_implemented', - 'internal', -]); - -export const kimiErrorPayloadSchema: z.ZodType = z.lazy( - () => kimiErrorPayloadObjectSchema, -); - -const kimiErrorPayloadObjectSchema = z.object({ - code: kimiErrorCodeSchema, - message: z.string(), - name: z.string().optional(), - details: z.record(z.string(), z.unknown()).optional(), - retryable: z.boolean(), - cause: kimiErrorPayloadSchema.optional(), -}); - -export const taskInfoBaseSchema = z.object({ - taskId: z.string(), - description: z.string(), - status: taskLifecycleStatusSchema, - detached: z.boolean().optional(), - startedAt: z.number(), - endedAt: z.number().nullable(), - stopReason: z.string().optional(), - terminalNotificationSuppressed: z.boolean().optional(), - timeoutMs: z.number().optional(), -}); - -export const processTaskInfoSchema = taskInfoBaseSchema.extend({ - kind: z.literal('process'), - command: z.string(), - pid: z.number(), - exitCode: z.number().nullable(), -}); - -export const agentTaskInfoSchema = taskInfoBaseSchema.extend({ - kind: z.literal('agent'), - agentId: z.string().optional(), - subagentType: z.string().optional(), - model: z.string().optional(), - thinkingEffort: z.string().optional(), -}); - -export const questionTaskInfoSchema = taskInfoBaseSchema.extend({ - kind: z.literal('question'), - questionCount: z.number(), - toolCallId: z.string().optional(), -}); - -export const taskInfoSchema = z.discriminatedUnion('kind', [ - processTaskInfoSchema, - agentTaskInfoSchema, - questionTaskInfoSchema, -]); - -export const compactionResultSchema = z.object({ - summary: z.string(), - compactedCount: z.number(), - tokensBefore: z.number(), - tokensAfter: z.number(), - keptUserMessageCount: z.number().optional(), - keptHeadUserMessageCount: z.number().optional(), - droppedCount: z.number().optional(), -}) satisfies z.ZodType; - -export const toolUpdateSchema = z.object({ - kind: z.enum(['stdout', 'stderr', 'progress', 'status', 'custom']), - text: z.string().optional(), - percent: z.number().optional(), - customKind: z.string().optional(), - customData: z.unknown().optional(), - replace: z.boolean().optional(), -}) satisfies z.ZodType; - -export const mcpOAuthAuthorizationUrlUpdateDataSchema = z.object({ - serverName: z.string(), - authorizationUrl: z.string(), - expiresAt: z.number().optional(), -}) satisfies z.ZodType; - -export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'blocked']) satisfies z.ZodType; - -export const agentPhaseSchema = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('idle') }), - z.object({ - kind: z.literal('running'), - turnId: z.number(), - step: z.number(), - stepId: z.string(), - since: z.number(), - }), - z.object({ - kind: z.literal('tool_call'), - turnId: z.number(), - step: z.number(), - toolCallId: z.string(), - name: z.string(), - since: z.number(), - }), - z.object({ - kind: z.literal('retrying'), - turnId: z.number(), - step: z.number(), - stepId: z.string(), - failedAttempt: z.number(), - nextAttempt: z.number(), - maxAttempts: z.number(), - delayMs: z.number(), - errorName: z.string().optional(), - statusCode: z.number().optional(), - since: z.number(), - }), - z.object({ - kind: z.literal('awaiting_approval'), - turnId: z.number(), - step: z.number().optional(), - approval: z.unknown().optional(), - since: z.number(), - }), - z.object({ - kind: z.literal('interrupted'), - turnId: z.number(), - step: z.number().optional(), - reason: z.enum(['aborted', 'max_steps', 'error']), - message: z.string().optional(), - at: z.number(), - }), - z.object({ - kind: z.literal('ended'), - turnId: z.number(), - reason: turnEndReasonSchema, - durationMs: z.number().optional(), - at: z.number(), - }), -]); - -export const agentStatusUpdatedEventSchema = z.object({ - type: z.literal('agent.status.updated'), - agentId: z.string(), - model: z.string().optional(), - thinkingEffort: z.string().optional(), - contextTokens: z.number().optional(), - maxContextTokens: z.number().optional(), - contextUsage: z.number().optional(), - planMode: z.boolean().optional(), - swarmMode: z.boolean().optional(), - towerMode: z.boolean().optional(), - permission: permissionModeSchema.optional(), - usage: usageStatusSchema.optional(), - phase: agentPhaseSchema.optional(), -}); - -export const sessionMetaUpdatedEventSchema = z.object({ - type: z.literal('session.meta.updated'), - title: z.string().optional(), - patch: z.record(z.string(), z.unknown()).optional(), -}); - -export const agentCreatedEventSchema = z.object({ - type: z.literal('agent.created'), -}); - -export const agentDisposedEventSchema = z.object({ - type: z.literal('agent.disposed'), -}); - -export const sessionCreatedEventSchema = z.object({ - type: z.literal('event.session.created'), - session: sessionSchema, -}); - -export const sessionArchivedEventSchema = z.object({ - type: z.literal('event.session.archived'), - workspace_id: z.string().min(1), -}); - -export const sessionDeletedEventSchema = z.object({ - type: z.literal('event.session.deleted'), - workspace_id: z.string().min(1), -}); - -export const workspaceCreatedEventSchema = z.object({ - type: z.literal('event.workspace.created'), - workspace: workspaceSchema, -}); - -export const workspaceUpdatedEventSchema = z.object({ - type: z.literal('event.workspace.updated'), - workspace: workspaceSchema, -}); - -export const workspaceDeletedEventSchema = z.object({ - type: z.literal('event.workspace.deleted'), - workspace_id: z.string().min(1), - root: z.string().min(1), -}); - -export const sessionWorkChangedEventSchema = z.object({ - type: z.literal('event.session.work_changed'), - busy: z.boolean(), - main_turn_active: z.boolean().optional(), - pending_interaction: sessionPendingInteractionSchema.optional(), - last_turn_reason: z.enum(['completed', 'cancelled', 'failed']).optional(), -}); - -const legacySessionStatusSchema = z.enum([ - 'idle', - 'running', - 'awaiting_approval', - 'awaiting_question', - 'aborted', -]); - -export const sessionStatusChangedEventSchema = z.object({ - type: z.literal('event.session.status_changed'), - status: legacySessionStatusSchema, - previous_status: legacySessionStatusSchema, - current_prompt_id: z.string().min(1).optional(), -}); - -export const configChangedEventSchema = z.object({ - type: z.literal('event.config.changed'), - changedFields: z.array(z.string().min(1)), - config: configResponseSchema, -}); - -export const configWarningEventSchema = z.object({ - type: z.literal('event.config.warning'), - warnings: z.array( - z.object({ - domain: z.string().optional(), - message: z.string(), - }), - ), -}); - -export const modelCatalogChangedEventSchema = z.object({ - type: z.literal('event.model_catalog.changed'), - changed: z.array( - z.object({ - provider_id: z.string().min(1), - provider_name: z.string().min(1), - added: z.number().int().min(0), - removed: z.number().int().min(0), - }), - ), - unchanged: z.array(z.string().min(1)), - failed: z.array( - z.object({ - provider: z.string().min(1), - reason: z.string().min(1), - }), - ), -}); - -export const pluginChangedEventSchema = z.object({ - type: z.literal('event.plugin.changed'), -}); - -export const capabilityChangedEventSchema = z.object({ - type: z.literal('event.capability.changed'), - capability_id: z.string(), - install: z.object({ - running: z.boolean(), - step: z.string().optional(), - percent: z.number().optional(), - error: z.string().optional(), - note: z.string().optional(), - }), -}); - -export const diUnitChangedEventSchema = z.object({ - type: z.literal('event.di.unit_changed'), - scope: z.string().min(1), - token: z.string().min(1), - state: z.enum(['Pending', 'Activating', 'Active', 'Unloading', 'Failed']), - error: z.string().optional(), -}); - -export const goalUpdatedEventSchema = z.object({ - type: z.literal('goal.updated'), - agentId: z.string(), - snapshot: goalSnapshotSchema.nullable(), - change: goalChangeSchema.optional(), -}); - -export const skillActivatedEventSchema = z.object({ - type: z.literal('skill.activated'), - agentId: z.string(), - activationId: z.string(), - skillName: z.string(), - skillArgs: z.string().optional(), - trigger: z.enum(['user-slash', 'model-tool', 'nested-skill']), - skillPath: z.string().optional(), - skillSource: skillSourceSchema.optional(), -}); - -export const pluginCommandActivatedEventSchema = z.object({ - type: z.literal('plugin_command.activated'), - agentId: z.string(), - activationId: z.string(), - pluginId: z.string(), - commandName: z.string(), - commandArgs: z.string().optional(), - trigger: z.literal('user-slash'), -}) satisfies z.ZodType; - -export const errorEventSchema = kimiErrorPayloadObjectSchema.extend({ - type: z.literal('error'), - agentId: z.string(), -}); - -export const warningEventSchema = z.object({ - type: z.literal('warning'), - agentId: z.string(), - message: z.string(), - code: z.string().optional(), -}) satisfies z.ZodType; - -export const turnStartedEventSchema = z.object({ - type: z.literal('turn.started'), - agentId: z.string(), - turnId: z.number(), - origin: promptOriginSchema, - prompt: z.string().optional(), - promptId: z.string().optional(), - promptAttachments: z - .array( - z.union([ - z.object({ kind: z.enum(['image', 'video', 'audio']), fileId: z.string() }), - z.object({ - kind: z.literal('file'), - name: z.string(), - mediaType: z.string(), - size: z.number(), - path: z.string(), - }), - ]), - ) - .optional(), -}); - -export const turnEndedEventSchema = z.object({ - type: z.literal('turn.ended'), - agentId: z.string(), - time: z.number().optional(), - turnId: z.number(), - reason: turnEndReasonSchema, - error: kimiErrorPayloadSchema.optional(), - durationMs: z.number().optional(), - interruptReason: z - .enum(['user_cancelled', 'aborted', 'max_steps', 'error', 'filtered', 'blocked']) - .optional(), -}); - -export const turnStepStartedEventSchema = z.object({ - type: z.literal('turn.step.started'), - agentId: z.string(), - turnId: z.number(), - step: z.number(), - stepId: z.string().optional(), -}) satisfies z.ZodType; - -export const turnStepCompletedEventSchema = z.object({ - type: z.literal('turn.step.completed'), - agentId: z.string(), - turnId: z.number(), - step: z.number(), - stepId: z.string().optional(), - usage: tokenUsageSchema.optional(), - finishReason: z.string().optional(), - llmFirstTokenLatencyMs: z.number().optional(), - llmStreamDurationMs: z.number().optional(), - llmRequestBuildMs: z.number().optional(), - llmServerFirstTokenMs: z.number().optional(), - llmServerDecodeMs: z.number().optional(), - llmClientConsumeMs: z.number().optional(), - llmClientBlockedMs: z.number().optional(), - providerFinishReason: finishReasonSchema.optional(), - rawFinishReason: z.string().optional(), -}) satisfies z.ZodType; - -export const turnStepRetryingEventSchema = z.object({ - type: z.literal('turn.step.retrying'), - agentId: z.string(), - turnId: z.number(), - step: z.number(), - stepId: z.string().optional(), - failedAttempt: z.number(), - nextAttempt: z.number(), - maxAttempts: z.number(), - delayMs: z.number(), - errorName: z.string(), - errorMessage: z.string(), - statusCode: z.number().optional(), -}) satisfies z.ZodType; - -export const turnStepInterruptedEventSchema = z.object({ - type: z.literal('turn.step.interrupted'), - agentId: z.string(), - turnId: z.number(), - step: z.number(), - stepId: z.string().optional(), - reason: z.string(), - message: z.string().optional(), -}) satisfies z.ZodType; - -export const assistantDeltaEventSchema = z.object({ - type: z.literal('assistant.delta'), - agentId: z.string(), - turnId: z.number(), - delta: z.string(), -}) satisfies z.ZodType; - -export const hookResultEventSchema = z.object({ - type: z.literal('hook.result'), - agentId: z.string(), - turnId: z.number().optional(), - hookEvent: z.string(), - content: z.string(), - blocked: z.boolean().optional(), -}) satisfies z.ZodType; - -export const thinkingDeltaEventSchema = z.object({ - type: z.literal('thinking.delta'), - agentId: z.string(), - turnId: z.number(), - delta: z.string(), -}) satisfies z.ZodType; - -export const toolCallDeltaEventSchema = z.object({ - type: z.literal('tool.call.delta'), - agentId: z.string(), - turnId: z.number(), - toolCallId: z.string(), - name: z.string().optional(), - argumentsPart: z.string().optional(), -}) satisfies z.ZodType; - -export const toolCallStartedEventSchema = z.object({ - type: z.literal('tool.call.started'), - agentId: z.string(), - turnId: z.number(), - toolCallId: z.string(), - name: z.string(), - args: z.unknown(), - description: z.string().optional(), - display: ToolInputDisplaySchema.optional(), -}) satisfies z.ZodType; - -export const toolProgressEventSchema = z.object({ - type: z.literal('tool.progress'), - agentId: z.string(), - turnId: z.number(), - toolCallId: z.string(), - update: toolUpdateSchema, -}) satisfies z.ZodType; - -export const shellOutputEventSchema = z.object({ - type: z.literal('shell.output'), - agentId: z.string(), - commandId: z.string(), - update: toolUpdateSchema, - taskId: z.string().optional(), -}) satisfies z.ZodType; - -export const shellStartedEventSchema = z.object({ - type: z.literal('shell.started'), - agentId: z.string(), - commandId: z.string(), - taskId: z.string(), -}) satisfies z.ZodType; - -export const shellCompletedEventSchema = z.object({ - type: z.literal('shell.completed'), - agentId: z.string(), - commandId: z.string(), - isError: z.boolean(), - taskId: z.string().optional(), -}) satisfies z.ZodType; - -export const toolResultEventSchema = z.object({ - type: z.literal('tool.result'), - agentId: z.string(), - turnId: z.number(), - toolCallId: z.string(), - output: z.unknown(), - isError: z.boolean().optional(), - synthetic: z.boolean().optional(), -}) satisfies z.ZodType; - -export const subagentSpawnedEventSchema = z.object({ - type: z.literal('subagent.spawned'), - subagentId: z.string(), - subagentName: z.string(), - parentToolCallId: z.string(), - parentToolCallUuid: z.string().optional(), - parentAgentId: z.string().optional(), - callerAgentId: z.string().optional(), - description: z.string().optional(), - swarmIndex: z.number().optional(), - runInBackground: z.boolean(), - model: z.string().optional(), - thinkingEffort: z.string().optional(), - taskId: z.string().optional(), -}) satisfies z.ZodType; - -export const subagentStartedEventSchema = z.object({ - type: z.literal('subagent.started'), - subagentId: z.string(), -}) satisfies z.ZodType; - -export const subagentSuspendedEventSchema = z.object({ - type: z.literal('subagent.suspended'), - subagentId: z.string(), - reason: z.string(), -}) satisfies z.ZodType; - -export const subagentCompletedEventSchema = z.object({ - type: z.literal('subagent.completed'), - subagentId: z.string(), - resultSummary: z.string(), - usage: tokenUsageSchema.optional(), - contextTokens: z.number().optional(), -}) satisfies z.ZodType; - -export const subagentFailedEventSchema = z.object({ - type: z.literal('subagent.failed'), - subagentId: z.string(), - error: z.string(), -}) satisfies z.ZodType; - -export const compactionStartedEventSchema = z.object({ - type: z.literal('compaction.started'), - agentId: z.string(), - trigger: z.enum(['manual', 'auto']), - instruction: z.string().optional(), -}) satisfies z.ZodType; - -export const compactionBlockedEventSchema = z.object({ - type: z.literal('compaction.blocked'), - agentId: z.string(), - turnId: z.number().optional(), -}) satisfies z.ZodType; - -export const compactionCancelledEventSchema = z.object({ - type: z.literal('compaction.cancelled'), - agentId: z.string(), -}); - -export const compactionCompletedEventSchema = z.object({ - type: z.literal('compaction.completed'), - agentId: z.string(), - result: compactionResultSchema, -}) satisfies z.ZodType; - -export const taskStartedEventSchema = z.object({ - type: z.literal('task.started'), - agentId: z.string(), - info: taskInfoSchema, -}); - -export const taskTerminatedEventSchema = z.object({ - type: z.literal('task.terminated'), - agentId: z.string(), - info: taskInfoSchema, -}); - -export const backgroundTaskStartedEventSchema = z.object({ - type: z.literal('background.task.started'), - info: taskInfoSchema, -}); - -export const backgroundTaskTerminatedEventSchema = z.object({ - type: z.literal('background.task.terminated'), - info: taskInfoSchema, -}); - -export const cronFiredEventSchema = z.object({ - type: z.literal('cron.fired'), - origin: cronJobOriginSchema, - prompt: z.string(), -}); - -export const promptSubmittedEventSchema = z.object({ - type: z.literal('prompt.submitted'), - promptId: z.string(), - userMessageId: z.string(), - status: z.enum(['running', 'queued', 'blocked']), - content: z.array(messageContentSchema), - createdAt: isoDateTimeSchema, -}); - -export const promptCompletedEventSchema = z.object({ - type: z.literal('prompt.completed'), - agentId: z.string(), - promptId: z.string(), - finishedAt: isoDateTimeSchema, - reason: z.enum(['completed', 'failed', 'blocked']).optional(), -}); - -export const promptAbortedEventSchema = z.object({ - type: z.literal('prompt.aborted'), - agentId: z.string(), - promptId: z.string(), - abortedAt: isoDateTimeSchema, -}); - -export const promptSteeredEventSchema = z.object({ - type: z.literal('prompt.steered'), - agentId: z.string(), - activePromptId: z.string(), - promptIds: z.array(z.string()), - content: z.array(messageContentSchema), - steeredAt: isoDateTimeSchema, -}); - -export const toolListUpdatedReasonSchema = z.enum([ - 'mcp.connected', - 'mcp.disconnected', - 'mcp.failed', -]) satisfies z.ZodType; - -export const toolListUpdatedEventSchema = z.object({ - type: z.literal('tool.list.updated'), - agentId: z.string(), - reason: toolListUpdatedReasonSchema, - serverName: z.string(), -}) satisfies z.ZodType; - -export const mcpServerStatusPayloadSchema = z.object({ - name: z.string(), - transport: z.enum(['stdio', 'http']), - status: z.enum(['pending', 'connected', 'failed', 'disabled', 'needs-auth', 'removed']), - toolCount: z.number(), - error: z.string().optional(), -}) satisfies z.ZodType; - -export const mcpServerStatusEventSchema = z.object({ - type: z.literal('mcp.server.status'), - agentId: z.string(), - server: mcpServerStatusPayloadSchema, -}) satisfies z.ZodType; - -export const agentEventSchema = z.discriminatedUnion('type', [ - errorEventSchema, - warningEventSchema, - agentStatusUpdatedEventSchema, - agentCreatedEventSchema, - agentDisposedEventSchema, - sessionMetaUpdatedEventSchema, - sessionCreatedEventSchema, - sessionArchivedEventSchema, - sessionDeletedEventSchema, - workspaceCreatedEventSchema, - workspaceUpdatedEventSchema, - workspaceDeletedEventSchema, - sessionWorkChangedEventSchema, - sessionStatusChangedEventSchema, - configChangedEventSchema, - configWarningEventSchema, - modelCatalogChangedEventSchema, - diUnitChangedEventSchema, - pluginChangedEventSchema, - capabilityChangedEventSchema, - goalUpdatedEventSchema, - skillActivatedEventSchema, - pluginCommandActivatedEventSchema, - turnStartedEventSchema, - turnEndedEventSchema, - turnStepStartedEventSchema, - turnStepCompletedEventSchema, - turnStepRetryingEventSchema, - turnStepInterruptedEventSchema, - assistantDeltaEventSchema, - hookResultEventSchema, - thinkingDeltaEventSchema, - toolCallDeltaEventSchema, - toolCallStartedEventSchema, - toolProgressEventSchema, - shellOutputEventSchema, - shellStartedEventSchema, - shellCompletedEventSchema, - toolResultEventSchema, - toolListUpdatedEventSchema, - mcpServerStatusEventSchema, - subagentSpawnedEventSchema, - subagentStartedEventSchema, - subagentSuspendedEventSchema, - subagentCompletedEventSchema, - subagentFailedEventSchema, - compactionStartedEventSchema, - compactionBlockedEventSchema, - compactionCancelledEventSchema, - compactionCompletedEventSchema, - taskStartedEventSchema, - taskTerminatedEventSchema, - backgroundTaskStartedEventSchema, - backgroundTaskTerminatedEventSchema, - cronFiredEventSchema, - promptSubmittedEventSchema, - promptCompletedEventSchema, - promptAbortedEventSchema, - promptSteeredEventSchema, -]); - -export const eventSchema = agentEventSchema.and( - z.object({ - agentId: z.string(), - sessionId: z.string(), - }), -); diff --git a/packages/kap-server/src/protocol/messages/session.ts b/packages/kap-server/src/protocol/messages/session.ts index 4666280b1f2..ea7bf3ec9d3 100644 --- a/packages/kap-server/src/protocol/messages/session.ts +++ b/packages/kap-server/src/protocol/messages/session.ts @@ -68,7 +68,6 @@ export const sessionInfoSchema = z.object({ usage: sessionInfoUsageSchema, permission_rules: z.array(sessionInfoPermissionRuleSchema), message_count: z.number().int().nonnegative(), - last_seq: z.number().int().nonnegative(), }); export type SessionInfo = z.infer; diff --git a/packages/kap-server/src/protocol/rest-message.ts b/packages/kap-server/src/protocol/rest-message.ts deleted file mode 100644 index 362a456adca..00000000000 --- a/packages/kap-server/src/protocol/rest-message.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from 'zod'; - -import { messageRoleSchema, messageSchema } from './message'; - -import { cursorQuerySchema } from './pagination'; - -export const listMessagesQuerySchema = cursorQuerySchema.and( - z.object({ - role: messageRoleSchema.optional(), - }), -); -export type ListMessagesQuery = z.infer; - -export const listMessagesResponseSchema = z.object({ - items: z.array(messageSchema), - has_more: z.boolean(), -}); -export type ListMessagesResponse = z.infer; - -export const getMessageResponseSchema = messageSchema; -export type GetMessageResponse = z.infer; diff --git a/packages/kap-server/src/protocol/rest-snapshot.ts b/packages/kap-server/src/protocol/rest-snapshot.ts deleted file mode 100644 index c247f8ce322..00000000000 --- a/packages/kap-server/src/protocol/rest-snapshot.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { z } from 'zod'; - -import { messageSchema } from './message'; - -import { approvalRequestSchema } from './approval'; -import { questionRequestSchema } from './question'; -import { sessionSchema } from './session'; -import { taskSchema } from './task'; - -export const inFlightToolCallSchema = z.object({ - tool_call_id: z.string().min(1), - name: z.string().min(1), - args: z.unknown().optional(), - description: z.string().optional(), - display: z.unknown().optional(), - last_progress: z - .object({ - kind: z.enum(['stdout', 'stderr', 'progress', 'status', 'custom']), - text: z.string().optional(), - percent: z.number().optional(), - }) - .optional(), -}); -export type InFlightToolCall = z.infer; - -export const inFlightTurnSchema = z.object({ - turn_id: z.number().int().nonnegative(), - assistant_text: z.string(), - thinking_text: z.string(), - running_tools: z.array(inFlightToolCallSchema), - current_prompt_id: z.string().optional(), -}); -export type InFlightTurn = z.infer; - -export const snapshotSubagentSchema = taskSchema.extend({ - subagent_phase: z.enum(['queued', 'working', 'suspended', 'completed', 'failed']).optional(), - subagent_type: z.string().optional(), - parent_tool_call_id: z.string().optional(), - suspended_reason: z.string().optional(), - swarm_index: z.number().int().nonnegative().optional(), - run_in_background: z.boolean().optional(), -}); -export type SnapshotSubagent = z.infer; - -export const sessionSnapshotResponseSchema = z.object({ - as_of_seq: z.number().int().nonnegative(), - epoch: z.string().min(1), - session: sessionSchema, - messages: z.object({ - items: z.array(messageSchema), - has_more: z.boolean(), - }), - in_flight_turn: inFlightTurnSchema.nullable(), - subagents: z.array(snapshotSubagentSchema).optional(), - pending_approvals: z.array(approvalRequestSchema), - pending_questions: z.array(questionRequestSchema), -}); -export type SessionSnapshotResponse = z.infer; diff --git a/packages/kap-server/src/protocol/session.ts b/packages/kap-server/src/protocol/session.ts index f408383451b..64084038928 100644 --- a/packages/kap-server/src/protocol/session.ts +++ b/packages/kap-server/src/protocol/session.ts @@ -58,7 +58,6 @@ export const sessionSchema = z.object({ usage: sessionUsageSchema, permission_rules: z.array(permissionRuleSchema), message_count: z.number().int().nonnegative(), - last_seq: z.number().int().nonnegative(), }); export type Session = z.infer; diff --git a/packages/kap-server/src/protocol/ws-control.ts b/packages/kap-server/src/protocol/ws-control.ts deleted file mode 100644 index f71b3700496..00000000000 --- a/packages/kap-server/src/protocol/ws-control.ts +++ /dev/null @@ -1,570 +0,0 @@ -import { z } from 'zod'; - -import { isoDateTimeSchema } from '@moonshot-ai/agent-core-v2/_base/utils/isoDateTime'; -import { transcriptGradeSpecSchema, transcriptSeqSchema } from '@moonshot-ai/transcript'; - -import { eventSchema } from './events-zod'; - -export const WS_PROTOCOL_VERSION = 2; - -export const sessionCursorSchema = z.object({ - seq: z.number().int().nonnegative(), - epoch: z.string().min(1).optional(), -}); - -export type SessionCursor = z.infer; - -export const cursorsBySessionSchema = z.record(z.string(), sessionCursorSchema); - -export type CursorsBySession = z.infer; - -export const wsEventEnvelopeSchema = (payload: T) => - z.object({ - type: z.string(), - seq: z.number().int().nonnegative(), - epoch: z.string().optional(), - volatile: z.boolean().optional(), - offset: z.number().int().nonnegative().optional(), - session_id: z.string().optional(), - timestamp: isoDateTimeSchema, - payload, - }); - -export const wsControlEnvelopeSchema = (payload: T) => - z.object({ - type: z.string(), - id: z.string().optional(), - payload, - }); - -export const wsAckEnvelopeSchema = (payload: T) => - z.object({ - type: z.literal('ack'), - id: z.string(), - code: z.number().int(), - msg: z.string(), - payload, - }); - -export const serverHelloPayloadSchema = z.object({ - ws_connection_id: z.string(), - protocol_version: z.number().int().positive(), - heartbeat_ms: z.number().int().positive().optional(), - max_event_buffer_size: z.number().int().positive(), - capabilities: z.object({ - event_batching: z.boolean(), - compression: z.boolean(), - }), -}); - -export const serverHelloMessageSchema = z.object({ - type: z.literal('server_hello'), - timestamp: isoDateTimeSchema, - payload: serverHelloPayloadSchema, -}); - -export type ServerHelloMessage = z.infer; - -export const agentFilterSchema = z.record(z.string(), z.array(z.string()).min(1)); - -export type AgentFilter = z.infer; - -export const clientHelloPayloadSchema = z.object({ - client_id: z.string(), - subscriptions: z.array(z.string()).optional(), - cursors: cursorsBySessionSchema.optional(), - agent_filter: agentFilterSchema.optional(), -}); - -export const clientHelloMessageSchema = z.object({ - type: z.literal('client_hello'), - id: z.string(), - payload: clientHelloPayloadSchema, -}); - -export type ClientHelloMessage = z.infer; - -export const clientHelloAckPayloadSchema = z.object({ - accepted_subscriptions: z.array(z.string()), - resync_required: z.array(z.string()), - cursors: cursorsBySessionSchema.optional(), -}); - -export const helloAckPayloadSchema = clientHelloAckPayloadSchema; - -export const clientHelloAckMessageSchema = wsAckEnvelopeSchema(clientHelloAckPayloadSchema); - -export const subscribePayloadSchema = z.object({ - session_ids: z.array(z.string()), - cursors: cursorsBySessionSchema.optional(), - agent_filter: agentFilterSchema.optional(), -}); - -export const subscribeMessageSchema = z.object({ - type: z.literal('subscribe'), - id: z.string(), - payload: subscribePayloadSchema, -}); - -export type SubscribeMessage = z.infer; - -export const subscribeV2PayloadSchema = z.object({ - session_id: z.string().min(1), - transcript: transcriptGradeSpecSchema, - transcript_since: z.record(z.string(), transcriptSeqSchema).optional(), -}); - -export const subscribeV2MessageSchema = z.object({ - type: z.literal('subscribe_v2'), - id: z.string(), - payload: subscribeV2PayloadSchema, -}); - -export type SubscribeV2Message = z.infer; - -export const unsubscribeV2PayloadSchema = z.object({ - session_id: z.string().min(1), - agent_ids: z.array(z.string().min(1)).min(1).optional(), -}); - -export const unsubscribeV2MessageSchema = z.object({ - type: z.literal('unsubscribe_v2'), - id: z.string(), - payload: unsubscribeV2PayloadSchema, -}); - -export type UnsubscribeV2Message = z.infer; - -export const subscribeAckPayloadSchema = z.object({ - accepted: z.array(z.string()), - not_found: z.array(z.string()), - resync_required: z.array(z.string()), - cursors: cursorsBySessionSchema.optional(), -}); - -export const subscribeAckMessageSchema = wsAckEnvelopeSchema(subscribeAckPayloadSchema); - -export const subscribeV2AckMessageSchema = wsAckEnvelopeSchema(subscribeAckPayloadSchema); - -export const unsubscribeV2AckMessageSchema = wsAckEnvelopeSchema(subscribeAckPayloadSchema); - -export const unsubscribePayloadSchema = z.object({ - session_ids: z.array(z.string()), -}); - -export const unsubscribeMessageSchema = z.object({ - type: z.literal('unsubscribe'), - id: z.string(), - payload: unsubscribePayloadSchema, -}); - -export type UnsubscribeMessage = z.infer; - -export const unsubscribeAckPayloadSchema = subscribeAckPayloadSchema; - -export const unsubscribeAckMessageSchema = wsAckEnvelopeSchema(unsubscribeAckPayloadSchema); - -export const abortPayloadSchema = z.object({ - session_id: z.string(), - prompt_id: z.string(), -}); - -export const abortMessageSchema = z.object({ - type: z.literal('abort'), - id: z.string(), - payload: abortPayloadSchema, -}); - -export type AbortMessage = z.infer; - -export const abortAckPayloadSchema = z.object({ - aborted: z.boolean().optional(), - at_seq: z.number().int().nonnegative().optional(), -}); - -export const abortAckMessageSchema = wsAckEnvelopeSchema(abortAckPayloadSchema); - -export const terminalAttachPayloadSchema = z.object({ - session_id: z.string().min(1), - terminal_id: z.string().min(1), - since_seq: z.number().int().nonnegative().optional(), -}); - -export const terminalAttachMessageSchema = z.object({ - type: z.literal('terminal_attach'), - id: z.string(), - payload: terminalAttachPayloadSchema, -}); - -export type TerminalAttachMessage = z.infer; - -export const terminalAttachAckPayloadSchema = z.object({ - attached: z.literal(true), - replayed: z.number().int().nonnegative(), -}); - -export const terminalAttachAckMessageSchema = wsAckEnvelopeSchema( - terminalAttachAckPayloadSchema, -); - -export const terminalDetachPayloadSchema = z.object({ - session_id: z.string().min(1), - terminal_id: z.string().min(1), -}); - -export const terminalDetachMessageSchema = z.object({ - type: z.literal('terminal_detach'), - id: z.string(), - payload: terminalDetachPayloadSchema, -}); - -export type TerminalDetachMessage = z.infer; - -export const terminalDetachAckPayloadSchema = z.object({ - detached: z.literal(true), -}); - -export const terminalDetachAckMessageSchema = wsAckEnvelopeSchema( - terminalDetachAckPayloadSchema, -); - -export const terminalInputPayloadSchema = z.object({ - session_id: z.string().min(1), - terminal_id: z.string().min(1), - data: z.string(), -}); - -export const terminalInputMessageSchema = z.object({ - type: z.literal('terminal_input'), - id: z.string(), - payload: terminalInputPayloadSchema, -}); - -export type TerminalInputMessage = z.infer; - -export const terminalInputAckPayloadSchema = z.object({ - accepted: z.literal(true), -}); - -export const terminalInputAckMessageSchema = wsAckEnvelopeSchema( - terminalInputAckPayloadSchema, -); - -export const terminalResizePayloadSchema = z.object({ - session_id: z.string().min(1), - terminal_id: z.string().min(1), - cols: z.number().int().positive(), - rows: z.number().int().positive(), -}); - -export const terminalResizeMessageSchema = z.object({ - type: z.literal('terminal_resize'), - id: z.string(), - payload: terminalResizePayloadSchema, -}); - -export type TerminalResizeMessage = z.infer; - -export const terminalResizeAckPayloadSchema = z.object({ - resized: z.literal(true), -}); - -export const terminalResizeAckMessageSchema = wsAckEnvelopeSchema( - terminalResizeAckPayloadSchema, -); - -export const terminalClosePayloadSchema = z.object({ - session_id: z.string().min(1), - terminal_id: z.string().min(1), -}); - -export const terminalCloseMessageSchema = z.object({ - type: z.literal('terminal_close'), - id: z.string(), - payload: terminalClosePayloadSchema, -}); - -export type TerminalCloseMessage = z.infer; - -export const terminalCloseAckPayloadSchema = z.object({ - closed: z.literal(true), -}); - -export const terminalCloseAckMessageSchema = wsAckEnvelopeSchema( - terminalCloseAckPayloadSchema, -); - -export const pingPayloadSchema = z.object({ - nonce: z.string(), -}); - -export const pingMessageSchema = z.object({ - type: z.literal('ping'), - timestamp: isoDateTimeSchema, - payload: pingPayloadSchema, -}); - -export type PingMessage = z.infer; - -export const pongPayloadSchema = z.object({ - nonce: z.string(), -}); - -export const pongMessageSchema = z.object({ - type: z.literal('pong'), - payload: pongPayloadSchema, -}); - -export type PongMessage = z.infer; - -export const resyncRequiredPayloadSchema = z.object({ - session_id: z.string(), - reason: z.enum(['buffer_overflow', 'session_recreated', 'epoch_changed']), - current_seq: z.number().int().nonnegative(), - epoch: z.string().min(1).optional(), -}); - -export const resyncRequiredMessageSchema = z.object({ - type: z.literal('resync_required'), - timestamp: isoDateTimeSchema, - payload: resyncRequiredPayloadSchema, -}); - -export type ResyncRequiredMessage = z.infer; - -export const wsErrorPayloadSchema = z.object({ - code: z.number().int(), - msg: z.string(), - fatal: z.boolean(), - request_id: z.string().optional(), - details: z.unknown().optional(), -}); - -export const wsErrorMessageSchema = z.object({ - type: z.literal('error'), - timestamp: isoDateTimeSchema, - payload: wsErrorPayloadSchema, -}); - -export type WsErrorMessage = z.infer; - -export const sessionEventMessageSchema = wsEventEnvelopeSchema(eventSchema); - -export const terminalOutputPayloadSchema = z.object({ - data: z.string(), -}); - -export const terminalOutputMessageSchema = z.object({ - type: z.literal('terminal_output'), - seq: z.number().int().positive(), - session_id: z.string().min(1), - terminal_id: z.string().min(1), - timestamp: isoDateTimeSchema, - payload: terminalOutputPayloadSchema, -}); - -export type TerminalOutputMessage = z.infer; - -export const terminalExitPayloadSchema = z.object({ - exit_code: z.number().int().nullable().optional(), -}); - -export const terminalExitMessageSchema = z.object({ - type: z.literal('terminal_exit'), - session_id: z.string().min(1), - terminal_id: z.string().min(1), - timestamp: isoDateTimeSchema, - payload: terminalExitPayloadSchema, -}); - -export type TerminalExitMessage = z.infer; - -export const clientControlMessageSchema = z.discriminatedUnion('type', [ - clientHelloMessageSchema, - subscribeMessageSchema, - subscribeV2MessageSchema, - unsubscribeMessageSchema, - unsubscribeV2MessageSchema, - abortMessageSchema, - terminalAttachMessageSchema, - terminalDetachMessageSchema, - terminalInputMessageSchema, - terminalResizeMessageSchema, - terminalCloseMessageSchema, - pongMessageSchema, -]); - -export type ClientControlMessage = z.infer; - -export const serverSystemMessageSchema = z.discriminatedUnion('type', [ - serverHelloMessageSchema, - pingMessageSchema, - resyncRequiredMessageSchema, - wsErrorMessageSchema, -]); - -export type ServerSystemMessage = z.infer; - -export type WsOperationDirection = 'client_to_server' | 'server_to_client'; - -export type WsOperationKind = 'control' | 'system' | 'event'; - -export interface WsOperationDefinition { - readonly type: string; - readonly direction: WsOperationDirection; - readonly kind: WsOperationKind; - readonly messageSchema: z.ZodTypeAny; - readonly ackSchema?: z.ZodTypeAny; - readonly description: string; -} - -export const clientControlOperations = [ - { - type: 'client_hello', - direction: 'client_to_server', - kind: 'control', - messageSchema: clientHelloMessageSchema, - ackSchema: clientHelloAckMessageSchema, - description: 'Start a client session and optionally subscribe to existing daemon sessions.', - }, - { - type: 'subscribe', - direction: 'client_to_server', - kind: 'control', - messageSchema: subscribeMessageSchema, - ackSchema: subscribeAckMessageSchema, - description: 'Subscribe the connection to one or more session event streams.', - }, - { - type: 'subscribe_v2', - direction: 'client_to_server', - kind: 'control', - messageSchema: subscribeV2MessageSchema, - ackSchema: subscribeV2AckMessageSchema, - description: - "Attach or update this connection's per-agent transcript grade stream for one session.", - }, - { - type: 'unsubscribe_v2', - direction: 'client_to_server', - kind: 'control', - messageSchema: unsubscribeV2MessageSchema, - ackSchema: unsubscribeV2AckMessageSchema, - description: - "Detach this connection's transcript grade stream for one session, optionally per agent.", - }, - { - type: 'unsubscribe', - direction: 'client_to_server', - kind: 'control', - messageSchema: unsubscribeMessageSchema, - ackSchema: unsubscribeAckMessageSchema, - description: 'Remove one or more session event stream subscriptions.', - }, - { - type: 'abort', - direction: 'client_to_server', - kind: 'control', - messageSchema: abortMessageSchema, - ackSchema: abortAckMessageSchema, - description: 'Abort a running prompt in a session.', - }, - { - type: 'terminal_attach', - direction: 'client_to_server', - kind: 'control', - messageSchema: terminalAttachMessageSchema, - ackSchema: terminalAttachAckMessageSchema, - description: 'Attach this connection to a terminal stream.', - }, - { - type: 'terminal_detach', - direction: 'client_to_server', - kind: 'control', - messageSchema: terminalDetachMessageSchema, - ackSchema: terminalDetachAckMessageSchema, - description: 'Detach this connection from a terminal stream.', - }, - { - type: 'terminal_input', - direction: 'client_to_server', - kind: 'control', - messageSchema: terminalInputMessageSchema, - ackSchema: terminalInputAckMessageSchema, - description: 'Write raw input bytes to a terminal.', - }, - { - type: 'terminal_resize', - direction: 'client_to_server', - kind: 'control', - messageSchema: terminalResizeMessageSchema, - ackSchema: terminalResizeAckMessageSchema, - description: 'Resize a terminal.', - }, - { - type: 'terminal_close', - direction: 'client_to_server', - kind: 'control', - messageSchema: terminalCloseMessageSchema, - ackSchema: terminalCloseAckMessageSchema, - description: 'Close a terminal.', - }, - { - type: 'pong', - direction: 'client_to_server', - kind: 'control', - messageSchema: pongMessageSchema, - description: 'Reply to a server ping with the same nonce.', - }, -] as const satisfies readonly WsOperationDefinition[]; - -export const serverSystemOperations = [ - { - type: 'server_hello', - direction: 'server_to_client', - kind: 'system', - messageSchema: serverHelloMessageSchema, - description: 'Initial server greeting sent immediately after the socket opens.', - }, - { - type: 'ping', - direction: 'server_to_client', - kind: 'system', - messageSchema: pingMessageSchema, - description: 'Heartbeat ping sent by the server; clients must answer with pong.', - }, - { - type: 'resync_required', - direction: 'server_to_client', - kind: 'system', - messageSchema: resyncRequiredMessageSchema, - description: 'Signals that a client must rebuild local session state from REST history.', - }, - { - type: 'error', - direction: 'server_to_client', - kind: 'system', - messageSchema: wsErrorMessageSchema, - description: 'Server-side WebSocket protocol or runtime error.', - }, -] as const satisfies readonly WsOperationDefinition[]; - -export const sessionEventOperation = { - type: 'session_event', - direction: 'server_to_client', - kind: 'event', - messageSchema: sessionEventMessageSchema, - description: 'Session-scoped agent event envelope; frame type is the payload event type.', -} as const satisfies WsOperationDefinition; - -export const wsOperations = [ - ...clientControlOperations, - ...serverSystemOperations, - sessionEventOperation, -] as const satisfies readonly WsOperationDefinition[]; - -export function getClientControlOperation( - type: string, -): (typeof clientControlOperations)[number] | undefined { - return clientControlOperations.find((operation) => operation.type === type); -} diff --git a/packages/kap-server/src/routes/messages.ts b/packages/kap-server/src/routes/messages.ts deleted file mode 100644 index d3e3b80202d..00000000000 --- a/packages/kap-server/src/routes/messages.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { type Scope } from '@moonshot-ai/agent-core-v2'; -import { ErrorCode } from '../protocol/error-codes'; -import { messageRoleSchema } from '../protocol/message'; -import { getMessageResponseSchema, listMessagesResponseSchema } from '../protocol/rest-message'; -import { z } from 'zod'; - -import { errEnvelope, okEnvelope } from '../envelope'; -import { requestLog } from '../lib/requestLog'; -import { defineRoute } from '../middleware/defineRoute'; -import { - getMessage, - listMessages, - MessageNotFoundError, - SessionNotFoundError, -} from '../services/messages/messageHistory'; - -interface MessageRouteHost { - get( - path: string, - options: { preHandler: unknown[]; schema?: Record } | undefined, - handler: ( - req: { id: string; query: unknown; params: unknown }, - reply: { send(payload: unknown): unknown }, - ) => Promise | void, - ): unknown; -} - -const messagesListQueryCoercion = z - .object({ - before_id: z.string().min(1).optional(), - after_id: z.string().min(1).optional(), - page_size: z.coerce.number().int().min(1).max(100).optional(), - role: messageRoleSchema.optional(), - }) - .superRefine((value, ctx) => { - if (value.before_id !== undefined && value.after_id !== undefined) { - ctx.addIssue({ - code: 'custom', - message: 'before_id and after_id are mutually exclusive', - path: ['before_id'], - params: { code: ErrorCode.VALIDATION_FAILED }, - }); - } - }); - -const sessionIdParamSchema = z.object({ - session_id: z.string().min(1), -}); - -const messageIdParamSchema = z.object({ - session_id: z.string().min(1), - message_id: z.string().min(1), -}); - -const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); - -export function registerMessagesRoutes(app: MessageRouteHost, core: Scope): void { - const listRoute = defineRoute( - { - method: 'GET', - path: '/sessions/{session_id}/messages', - params: sessionIdParamSchema, - querystring: messagesListQueryCoercion, - success: { data: listMessagesResponseSchema }, - errors: { - [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, - [ErrorCode.SESSION_NOT_FOUND]: {}, - }, - description: 'List messages for a session', - tags: ['messages'], - }, - async (req, reply) => { - try { - const { session_id } = req.params; - const page = await listMessages(core, session_id, req.query); - reply.send(okEnvelope(page, req.id)); - } catch (err) { - sendMappedError(reply, req, err); - } - }, - ); - app.get( - listRoute.path, - listRoute.options, - listRoute.handler as Parameters[2], - ); - - const getRoute = defineRoute( - { - method: 'GET', - path: '/sessions/{session_id}/messages/{message_id}', - params: messageIdParamSchema, - success: { data: getMessageResponseSchema }, - errors: { - [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, - [ErrorCode.SESSION_NOT_FOUND]: {}, - [ErrorCode.MESSAGE_NOT_FOUND]: {}, - }, - description: 'Get a message by ID', - tags: ['messages'], - }, - async (req, reply) => { - try { - const { session_id, message_id } = req.params; - const message = await getMessage(core, session_id, message_id); - reply.send(okEnvelope(message, req.id)); - } catch (err) { - sendMappedError(reply, req, err); - } - }, - ); - app.get( - getRoute.path, - getRoute.options, - getRoute.handler as Parameters[2], - ); -} - -function sendMappedError( - reply: { send(payload: unknown): unknown }, - req: { id: string }, - err: unknown, -): void { - const requestId = req.id; - const log = requestLog(req); - if (err instanceof SessionNotFoundError) { - reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); - return; - } - if (err instanceof MessageNotFoundError) { - reply.send(errEnvelope(ErrorCode.MESSAGE_NOT_FOUND, err.message, requestId, err.stack)); - return; - } - log?.error({ err }, 'message request failed'); - reply.send( - errEnvelope(ErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : String(err), requestId, err instanceof Error ? err.stack : undefined), - ); -} diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts index 84087caf68b..315204217ce 100644 --- a/packages/kap-server/src/routes/registerApiV1Routes.ts +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -8,9 +8,7 @@ import { ulid } from 'ulid'; import { okEnvelope } from '../envelope'; import type { MetaFeature } from '../protocol/rest-meta'; import { type IConnectionRegistry } from '../transport/ws/connectionRegistry'; -import { type SessionEventBroadcaster } from '../transport/ws/v1/sessionEventBroadcaster'; import type { ProjectionService } from '../services/projection'; -import type { TranscriptService } from '../services/transcript/transcriptService'; import { registerApprovalsRoutes } from './approvals'; import { registerAuthRoute } from './auth'; import { registerCapabilitiesRoutes } from './capabilities'; @@ -21,7 +19,6 @@ import { registerFilesRoutes } from './files'; import { registerFsRoutes } from './fs'; import { registerGuiStoreRoutes } from './guiStore'; import { registerHistoryRoutes } from './history'; -import { registerMessagesRoutes } from './messages'; import type { IGuiStoreService } from '../services/guiStore/guiStore'; import { registerDebugRoutes } from '../transport/registerDebugRoutes'; import { registerMetaRoute } from './meta'; @@ -37,12 +34,10 @@ import { registerSessionMediaRoutes } from './sessionMedia'; import { registerSessionExportRoute } from './sessionExport'; import { registerSessionsRoutes } from './sessions'; import { registerShutdownRoutes } from './shutdown'; -import { registerSnapshotRoutes } from './snapshot'; import { registerSkillsRoutes } from './skills'; import { registerTasksRoutes } from './tasks'; import { registerTerminalsRoutes } from './terminals'; import { registerToolsRoutes } from './tools'; -import { registerTranscriptRoutes } from './transcript'; import { registerWorkspaceFsRoutes } from './workspaceFs'; import { registerWorkspacesRoutes } from './workspaces'; @@ -70,8 +65,6 @@ export interface RegisterApiV1RoutesOptions { readonly guiStore: IGuiStoreService; readonly onShutdown: () => void; readonly connectionRegistry: IConnectionRegistry; - readonly broadcaster: SessionEventBroadcaster; - readonly transcriptService: TranscriptService; readonly homeDir: string; readonly projectionService: ProjectionService; readonly pluginMarketplaceUrl: () => string; @@ -125,7 +118,6 @@ export async function registerApiV1Routes( registerSessionsRoutes( apiV1 as unknown as Parameters[0], core, - { sessionEventCursor: (sessionId) => opts.broadcaster.getCursor(sessionId) }, ); registerRuntimeRoutes(apiV1 as unknown as Parameters[0], core); registerSessionExportRoute( @@ -142,10 +134,6 @@ export async function registerApiV1Routes( marketplaceUrl: opts.pluginMarketplaceUrl, marketplaceIsDefault: opts.pluginMarketplaceIsDefault, }); - registerMessagesRoutes( - apiV1 as unknown as Parameters[0], - core, - ); registerHistoryRoutes(apiV1 as unknown as Parameters[0], { core, homeDir: opts.homeDir, @@ -199,14 +187,6 @@ export async function registerApiV1Routes( apiV1 as unknown as Parameters[0], opts.connectionRegistry, ); - registerSnapshotRoutes(apiV1 as unknown as Parameters[0], { - core, - broadcaster: opts.broadcaster, - }); - registerTranscriptRoutes(apiV1 as unknown as Parameters[0], { - core, - transcriptService: opts.transcriptService, - }); if (opts.enableShutdown !== false) { registerShutdownRoutes(apiV1 as unknown as Parameters[0], { onShutdown: opts.onShutdown, diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index d4799d6fd20..3b769493ef7 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -167,14 +167,9 @@ const sessionActionRequestSchema = z.preprocess( const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); -export interface SessionsRoutesDeps { - readonly sessionEventCursor: (sessionId: string) => Promise<{ seq: number; epoch: string }>; -} - export function registerSessionsRoutes( app: SessionRouteHost, core: Scope, - deps: SessionsRoutesDeps, ): void { const createRoute = defineRoute( { @@ -412,7 +407,6 @@ export function registerSessionsRoutes( }, async (req, reply) => { const { session_id } = req.params; - const cursor = await deps.sessionEventCursor(session_id); const summary = await core.accessor.get(ISessionIndex).get(session_id); if (summary === undefined) { reply.send( @@ -433,10 +427,7 @@ export function registerSessionsRoutes( return; } reply.send( - okEnvelope( - toWireSession(summary, cwd, resolveSessionFacts(core, session_id), cursor.seq), - req.id, - ), + okEnvelope(toWireSession(summary, cwd, resolveSessionFacts(core, session_id)), req.id), ); }, ); @@ -1025,7 +1016,6 @@ export function toWireSession( fields: SessionWireFields, cwd: string, facts: SessionFacts, - lastSeq?: number, ): Session { return { id: fields.id, @@ -1047,7 +1037,6 @@ export function toWireSession( usage: emptySessionUsage(), permission_rules: [], message_count: 0, - last_seq: lastSeq ?? 0, }; } diff --git a/packages/kap-server/src/routes/snapshot.ts b/packages/kap-server/src/routes/snapshot.ts deleted file mode 100644 index 87bb7999754..00000000000 --- a/packages/kap-server/src/routes/snapshot.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { - IAgentPromptService, - INTERACTION_TAG_SESSION_ID, - ISessionContext, - ISessionMetadata, - IWorkspaceService, - interactions, - resumeSessionById, - type IAgentScopeHandle, - type Scope, -} from '@moonshot-ai/agent-core-v2'; -import { z } from 'zod'; - -import { errEnvelope, okEnvelope } from '../envelope'; -import { ensureMainAgent } from '../transport/mainAgent'; -import { defineRoute } from '../middleware/defineRoute'; -import { ErrorCode } from '../protocol/error-codes'; -import { - sessionSnapshotResponseSchema, - type InFlightTurn, - type SessionSnapshotResponse, -} from '../protocol/rest-snapshot'; -import { emptySessionUsage, type SessionUsage } from '../protocol/session'; -import { - readLegacyStatus, - type LegacyStatusSnapshot, -} from '../services/legacyStatus/legacyStatus'; -import { loadMessageHistory } from '../services/messages/messageHistory'; -import { type SessionEventBroadcaster } from '../transport/ws/v1/sessionEventBroadcaster'; -import { toWireApproval } from './approvals'; -import { toWireQuestion } from '../protocol/question-wire'; -import { resolveSessionFacts, toWireSession } from './sessions'; - -const SNAPSHOT_MESSAGE_PAGE_SIZE = 100; - -class SnapshotNotFoundError extends Error { - constructor(sessionId: string) { - super(`session ${sessionId} does not exist`); - this.name = 'SnapshotNotFoundError'; - } -} - -const sessionIdParamSchema = z.object({ - session_id: z.string().min(1), -}); - -interface SnapshotRouteHost { - get( - path: string, - options: { preHandler: unknown[]; schema?: Record } | undefined, - handler: ( - req: { id: string; params: { session_id: string } }, - reply: { send(payload: unknown): unknown }, - ) => Promise | void, - ): unknown; -} - -export interface SnapshotRouteDeps { - readonly core: Scope; - readonly broadcaster: SessionEventBroadcaster; -} - -export function registerSnapshotRoutes(app: SnapshotRouteHost, deps: SnapshotRouteDeps): void { - const { core, broadcaster } = deps; - - const route = defineRoute( - { - method: 'GET', - path: '/sessions/{session_id}/snapshot', - params: sessionIdParamSchema, - success: { data: sessionSnapshotResponseSchema }, - errors: { - [ErrorCode.SESSION_NOT_FOUND]: {}, - [ErrorCode.INTERNAL_ERROR]: {}, - }, - description: - 'Atomic session snapshot for client rebuild: state + as_of_seq watermark + epoch', - tags: ['sessions'], - }, - async (req, reply) => { - const { session_id } = req.params; - try { - const data = await assembleSnapshot(core, broadcaster, session_id); - reply.send(okEnvelope(data, req.id)); - } catch (err) { - if (err instanceof SnapshotNotFoundError) { - reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, req.id, err.stack)); - return; - } - throw err; - } - }, - ); - app.get(route.path, route.options, route.handler as Parameters[2]); -} - -async function assembleSnapshot( - core: Scope, - broadcaster: SessionEventBroadcaster, - sessionId: string, -): Promise { - const handle = await resumeSessionById(core.accessor, sessionId); - if (handle === undefined) { - throw new SnapshotNotFoundError(sessionId); - } - - const snapState = await broadcaster.getSnapshotState(sessionId); - - const workspaceId = handle.accessor.get(ISessionContext).workspaceId; - const workspace = await core.accessor.get(IWorkspaceService).get(workspaceId); - const cwd = workspace?.root ?? ''; - const meta = await handle.accessor.get(ISessionMetadata).read(); - - const main = await ensureMainAgent(handle); - const status = readLegacyStatus(main); - const session = { - ...toWireSession( - { ...meta, workspaceId }, - cwd, - resolveSessionFacts(core, sessionId), - ), - agent_config: { model: status?.model ?? '' }, - usage: toSnapshotUsage(status), - }; - - const all = await loadMessageHistory(core, main, sessionId, meta.createdAt); - const hasMore = all.length > SNAPSHOT_MESSAGE_PAGE_SIZE; - const items = all.slice(-SNAPSHOT_MESSAGE_PAGE_SIZE); - - const currentPromptId = snapState.inFlightTurn === null ? undefined : readCurrentPromptId(main); - const inFlightTurn = attachCurrentPromptIdToInFlight(snapState.inFlightTurn, currentPromptId); - - const pendingApprovals = interactions - .findAll({ - kind: 'approval', - resolved: false, - tags: { [INTERACTION_TAG_SESSION_ID]: sessionId }, - }) - .map((i) => toWireApproval(i, sessionId)); - const pendingQuestions = interactions - .findAll({ - kind: 'question', - resolved: false, - tags: { [INTERACTION_TAG_SESSION_ID]: sessionId }, - }) - .map((i) => toWireQuestion(i, sessionId)); - - return { - as_of_seq: snapState.seq, - epoch: snapState.epoch, - session, - messages: { items, has_more: hasMore }, - in_flight_turn: inFlightTurn, - subagents: snapState.subagents, - pending_approvals: pendingApprovals, - pending_questions: pendingQuestions, - }; -} - -function readCurrentPromptId(main: IAgentScopeHandle | undefined): string | undefined { - if (main === undefined) return undefined; - try { - return main.accessor.get(IAgentPromptService).list().active?.id; - } catch { - return undefined; - } -} - -function toSnapshotUsage(status: LegacyStatusSnapshot | undefined): SessionUsage { - if (status === undefined) return emptySessionUsage(); - const total = status.usage?.total; - return { - input_tokens: total?.inputOther ?? 0, - output_tokens: total?.output ?? 0, - cache_read_tokens: total?.inputCacheRead ?? 0, - cache_creation_tokens: total?.inputCacheCreation ?? 0, - context_tokens: status.contextTokens, - context_limit: status.maxContextTokens, - }; -} - -function attachCurrentPromptIdToInFlight( - inFlightTurn: InFlightTurn | null, - currentPromptId: string | undefined, -): InFlightTurn | null { - if (inFlightTurn === null || currentPromptId === undefined) return inFlightTurn; - return { ...inFlightTurn, current_prompt_id: currentPromptId }; -} diff --git a/packages/kap-server/src/routes/transcript.ts b/packages/kap-server/src/routes/transcript.ts deleted file mode 100644 index 069f197b168..00000000000 --- a/packages/kap-server/src/routes/transcript.ts +++ /dev/null @@ -1,584 +0,0 @@ -import { MAIN_AGENT_ID, type Scope } from '@moonshot-ai/agent-core-v2'; -import { - isPlainAgentId, - paginateTurns, - transcriptOpsCatchupResponseSchema, - transcriptPlanResponseSchema, - transcriptResponseSchema, - transcriptUserMessagesResponseSchema, - type ToolCallFrame, - type TranscriptAttachment, - type TranscriptInteraction, - type TranscriptItem, - type TurnOrigin, - type TurnState, -} from '@moonshot-ai/transcript'; -import { z } from 'zod'; - -import { errEnvelope, okEnvelope } from '../envelope'; -import { ErrorCode } from '../protocol/error-codes'; -import { defineRoute } from '../middleware/defineRoute'; -import type { TranscriptService } from '../services/transcript/transcriptService'; - -interface TranscriptRouteHost { - get( - path: string, - options: { preHandler: unknown[]; schema?: Record } | undefined, - handler: ( - req: { id: string; query: unknown; params: unknown }, - reply: { send(payload: unknown): unknown }, - ) => Promise | void, - ): unknown; -} - -const sessionIdParamSchema = z.object({ - session_id: z.string().min(1), -}); - -const transcriptQueryCoercion = z - .object({ - agent_id: z.string().min(1), - before_turn: z.string().min(1).optional(), - after_turn: z.string().min(1).optional(), - page_size: z.coerce.number().int().min(1).max(100).optional(), - }) - .superRefine((value, ctx) => { - if (value.before_turn !== undefined && value.after_turn !== undefined) { - ctx.addIssue({ - code: 'custom', - message: 'before_turn and after_turn are mutually exclusive', - path: ['before_turn'], - params: { code: ErrorCode.VALIDATION_FAILED }, - }); - } - if (!isPlainAgentId(value.agent_id)) { - ctx.addIssue({ - code: 'custom', - message: 'agent_id must be a plain agent id (no path separators)', - path: ['agent_id'], - params: { code: ErrorCode.VALIDATION_FAILED }, - }); - } - }); - -const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); - -const transcriptOpsQueryCoercion = z - .object({ - agent_id: z.string().min(1), - since_seq: z.coerce.number().int().min(0), - }) - .superRefine((value, ctx) => { - if (!isPlainAgentId(value.agent_id)) { - ctx.addIssue({ - code: 'custom', - message: 'agent_id must be a plain agent id (no path separators)', - path: ['agent_id'], - params: { code: ErrorCode.VALIDATION_FAILED }, - }); - } - }); - -const DEFAULT_PAGE_SIZE = 20; - -const userMessagesQueryCoercion = z - .object({ - agent_id: z.string().min(1).optional(), - }) - .superRefine((value, ctx) => { - if (value.agent_id !== undefined && !isPlainAgentId(value.agent_id)) { - ctx.addIssue({ - code: 'custom', - message: 'agent_id must be a plain agent id (no path separators)', - path: ['agent_id'], - params: { code: ErrorCode.VALIDATION_FAILED }, - }); - } - }); - -const planQueryCoercion = z - .object({ - agent_id: z.string().min(1), - tool_call_id: z.string().min(1).optional(), - }) - .superRefine((value, ctx) => { - if (!isPlainAgentId(value.agent_id)) { - ctx.addIssue({ - code: 'custom', - message: 'agent_id must be a plain agent id (no path separators)', - path: ['agent_id'], - params: { code: ErrorCode.VALIDATION_FAILED }, - }); - } - }); - -export interface TranscriptRouteDeps { - readonly core: Scope; - readonly transcriptService: TranscriptService; -} - -export function registerTranscriptRoutes(app: TranscriptRouteHost, deps: TranscriptRouteDeps): void { - const { transcriptService } = deps; - - const route = defineRoute( - { - method: 'GET', - path: '/sessions/{session_id}/transcript', - params: sessionIdParamSchema, - querystring: transcriptQueryCoercion, - success: { data: transcriptResponseSchema }, - errors: { - [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, - [ErrorCode.SESSION_NOT_FOUND]: {}, - }, - description: - 'Turn-granular session transcript page: live sessions read the in-memory store (wire-records backfill awaited per requested agent), cold sessions rebuild the requested agent from the persisted wire records', - tags: ['transcript'], - }, - async (req, reply) => { - const { session_id } = req.params; - const query = req.query; - const pageQuery = { - beforeTurn: query.before_turn, - afterTurn: query.after_turn, - pageSize: query.page_size ?? DEFAULT_PAGE_SIZE, - }; - - const store = transcriptService.forSessionLive(session_id); - if (store !== undefined) { - await transcriptService.whenReady(session_id); - await transcriptService.ensureAgentHistory(session_id, query.agent_id); - const transcript = store.ensureAgent(query.agent_id); - const page = paginateTurns(transcript.getItems(), pageQuery); - reply.send( - okEnvelope( - { - agent_id: query.agent_id, - items: page.items, - has_more: page.hasMore, - tasks: [...transcript.getTasks().values()], - interactions: [...transcript.getInteractions().values()], - attachments: [...transcript.getAttachments().values()], - todos: [...transcript.getTodos().values()], - prompts: [...transcript.getPrompts().values()], - meta: transcript.getMeta(), - agents: store.agents(), - pending_interactions: transcript.listPendingInteractions(), - seq: transcriptService.getSeqWatermark(session_id, query.agent_id), - }, - req.id, - ), - ); - return; - } - - const snapshot = await transcriptService.readColdSnapshot(session_id, query.agent_id); - if (snapshot === undefined) { - sendSessionNotFound(reply, req.id, session_id); - return; - } - const page = paginateTurns(snapshot.items, pageQuery); - const roster = (await transcriptService.readColdRoster(session_id)) ?? []; - if ( - !roster.some((d) => d.agentId === query.agent_id) && - (snapshot.items.length > 0 || snapshot.tasks.length > 0 || query.agent_id === MAIN_AGENT_ID) - ) { - roster.push({ - agentId: query.agent_id, - type: query.agent_id === MAIN_AGENT_ID ? ('main' as const) : ('sub' as const), - }); - } - reply.send( - okEnvelope( - { - agent_id: query.agent_id, - items: page.items, - has_more: page.hasMore, - tasks: snapshot.tasks, - interactions: snapshot.interactions, - attachments: snapshot.attachments, - todos: snapshot.todos, - prompts: snapshot.prompts, - meta: snapshot.meta, - agents: roster, - pending_interactions: [], - }, - req.id, - ), - ); - }, - ); - app.get(route.path, route.options, route.handler as Parameters[2]); - - const opsRoute = defineRoute( - { - method: 'GET', - path: '/sessions/{session_id}/transcript/ops', - params: sessionIdParamSchema, - querystring: transcriptOpsQueryCoercion, - success: { data: transcriptOpsCatchupResponseSchema }, - errors: { - [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, - [ErrorCode.SESSION_NOT_FOUND]: {}, - }, - description: - 'Point-to-point transcript catch-up: journaled op batches with seq > since_seq for one agent, oldest first. complete:false means the session is not live or the journal no longer reaches back to since_seq — the caller must fall back to a full transcript refresh', - tags: ['transcript'], - }, - async (req, reply) => { - const { session_id } = req.params; - const query = req.query; - - const catchup = transcriptService.getOpsSince(session_id, query.agent_id, query.since_seq); - if (catchup === undefined) { - const roster = await transcriptService.readColdRoster(session_id); - if (roster === undefined) { - sendSessionNotFound(reply, req.id, session_id); - return; - } - reply.send( - okEnvelope( - { agent_id: query.agent_id, batches: [], latest_seq: 0, complete: false }, - req.id, - ), - ); - return; - } - reply.send( - okEnvelope( - { - agent_id: query.agent_id, - batches: catchup.batches, - latest_seq: catchup.latestSeq, - complete: catchup.complete, - }, - req.id, - ), - ); - }, - ); - app.get(opsRoute.path, opsRoute.options, opsRoute.handler as Parameters[2]); - - const userMessagesRoute = defineRoute( - { - method: 'GET', - path: '/sessions/{session_id}/transcript/user-messages', - params: sessionIdParamSchema, - querystring: userMessagesQueryCoercion, - success: { data: transcriptUserMessagesResponseSchema }, - errors: { - [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, - [ErrorCode.SESSION_NOT_FOUND]: {}, - }, - description: - 'All turn-opening inputs ("user messages") of a session, grouped per agent: every turn with a defined prompt (real user text, user-slash skill/plugin commands, cron prompts — distinguish via origin), plus attachment-only prompts projected with an empty prompt string. agent_id optional: present reads one agent, absent reads every rostered agent. Live sessions answer from the in-memory store (history backfill awaited per agent), cold sessions rebuild from the persisted wire records. Unpaginated; attachment entities referenced by the messages ride along (metadata only)', - tags: ['transcript'], - }, - async (req, reply) => { - const { session_id } = req.params; - const { agent_id } = req.query; - - const store = transcriptService.forSessionLive(session_id); - if (store !== undefined) { - await transcriptService.whenReady(session_id); - const agentIds = - agent_id !== undefined ? [agent_id] : store.agents().map((d) => d.agentId); - const agents = []; - for (const agentId of agentIds) { - await transcriptService.ensureAgentHistory(session_id, agentId); - const transcript = store.ensureAgent(agentId); - const attachments = transcript.getAttachments(); - agents.push({ - agent_id: agentId, - ...projectUserMessages(transcript.getItems(), (id) => attachments.get(id)), - }); - } - reply.send(okEnvelope({ agents }, req.id)); - return; - } - - const roster = await transcriptService.readColdRoster(session_id); - if (roster === undefined) { - sendSessionNotFound(reply, req.id, session_id); - return; - } - const agentIds = agent_id !== undefined ? [agent_id] : roster.map((d) => d.agentId); - if (agent_id === undefined && !agentIds.includes(MAIN_AGENT_ID)) { - agentIds.unshift(MAIN_AGENT_ID); - } - const agents = []; - for (const agentId of agentIds) { - const snapshot = await transcriptService.readColdSnapshot(session_id, agentId); - if (snapshot === undefined) { - sendSessionNotFound(reply, req.id, session_id); - return; - } - const byId = new Map(snapshot.attachments.map((a) => [a.attachmentId, a])); - agents.push({ - agent_id: agentId, - ...projectUserMessages(snapshot.items, (id) => byId.get(id)), - }); - } - reply.send(okEnvelope({ agents }, req.id)); - }, - ); - app.get( - userMessagesRoute.path, - userMessagesRoute.options, - userMessagesRoute.handler as Parameters[2], - ); - - const planRoute = defineRoute( - { - method: 'GET', - path: '/sessions/{session_id}/transcript/plan', - params: sessionIdParamSchema, - querystring: planQueryCoercion, - success: { data: transcriptPlanResponseSchema }, - errors: { - [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, - [ErrorCode.SESSION_NOT_FOUND]: {}, - [ErrorCode.TOOL_CALL_NOT_FOUND]: {}, - }, - description: - 'Plan information of an agent\'s ExitPlanMode tool calls: the reviewed plan content, plan file path, offered options, and the review outcome, in timeline order. agent_id required; tool_call_id optional — present narrows the read to that one call (unknown id or non-ExitPlanMode call → 40416), absent lists every call with recoverable plan content. Content is projected from the linked approval interaction (interactive reviews, live or cold), the live tool frame display (auto mode), or the tool result output text (cold rebuilds without an interaction). Live sessions read the in-memory store (history backfill awaited), cold sessions rebuild the agent from the persisted wire records', - tags: ['transcript'], - }, - async (req, reply) => { - const { session_id } = req.params; - const { agent_id, tool_call_id } = req.query; - - const store = transcriptService.forSessionLive(session_id); - if (store !== undefined) { - await transcriptService.whenReady(session_id); - await transcriptService.ensureAgentHistory(session_id, agent_id); - const transcript = store.ensureAgent(agent_id); - const plans = projectPlans( - transcript.getItems(), - [...transcript.getInteractions().values()], - tool_call_id, - ); - if (tool_call_id !== undefined && plans.length === 0) { - sendToolCallNotFound(reply, req.id, tool_call_id); - return; - } - reply.send(okEnvelope({ agent_id, plans }, req.id)); - return; - } - - const snapshot = await transcriptService.readColdSnapshot(session_id, agent_id); - if (snapshot === undefined) { - sendSessionNotFound(reply, req.id, session_id); - return; - } - const plans = projectPlans(snapshot.items, snapshot.interactions, tool_call_id); - if (tool_call_id !== undefined && plans.length === 0) { - sendToolCallNotFound(reply, req.id, tool_call_id); - return; - } - reply.send(okEnvelope({ agent_id, plans }, req.id)); - }, - ); - app.get(planRoute.path, planRoute.options, planRoute.handler as Parameters[2]); -} - -interface UserMessageEntry { - turn_id: string; - ordinal: number; - state: TurnState; - origin: TurnOrigin; - prompt: string; - attachment_ids?: readonly string[]; - started_at?: string; -} - -function projectUserMessages( - items: readonly TranscriptItem[], - resolveAttachment: (id: string) => TranscriptAttachment | undefined, -): { messages: UserMessageEntry[]; attachments: TranscriptAttachment[] } { - const messages: UserMessageEntry[] = []; - const attachments = new Map(); - for (const item of items) { - if (item.kind !== 'turn') continue; - const hasAttachments = item.attachmentIds !== undefined && item.attachmentIds.length > 0; - if (item.prompt === undefined && !hasAttachments) continue; - messages.push({ - turn_id: item.turnId, - ordinal: item.ordinal, - state: item.state, - origin: item.origin, - prompt: item.prompt ?? '', - attachment_ids: item.attachmentIds, - started_at: item.startedAt, - }); - for (const id of item.attachmentIds ?? []) { - const attachment = resolveAttachment(id); - if (attachment !== undefined) attachments.set(id, attachment); - } - } - return { messages, attachments: [...attachments.values()] }; -} - -function sendSessionNotFound( - reply: { send(payload: unknown): unknown }, - requestId: string, - sessionId: string, -): void { - reply.send( - errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session not found: ${sessionId}`, requestId), - ); -} - -function sendToolCallNotFound( - reply: { send(payload: unknown): unknown }, - requestId: string, - toolCallId: string, -): void { - reply.send( - errEnvelope( - ErrorCode.TOOL_CALL_NOT_FOUND, - `no ExitPlanMode tool call found for tool_call_id: ${toolCallId}`, - requestId, - ), - ); -} - -interface PlanReviewInfo { - state: 'pending' | 'approved' | 'rejected' | 'cancelled'; - selected_option?: string; - feedback?: string; -} - -interface PlanInfo { - tool_call_id: string; - turn_id: string; - source: 'interaction' | 'display' | 'output'; - plan: string; - path?: string; - options?: { label: string; description?: string }[]; - review?: PlanReviewInfo; -} - -interface PlanReviewDisplayInfo { - plan: string; - path?: string; - options?: { label: string; description?: string }[]; -} - -function projectPlans( - items: readonly TranscriptItem[], - interactions: readonly TranscriptInteraction[], - toolCallId?: string, -): PlanInfo[] { - const plans: PlanInfo[] = []; - for (const item of items) { - if (item.kind !== 'turn') continue; - for (const step of item.steps) { - for (const frame of step.frames) { - if (frame.kind !== 'tool' || frame.name !== 'ExitPlanMode') continue; - if (toolCallId !== undefined && frame.toolCallId !== toolCallId) continue; - const info = projectPlanFrame(item.turnId, frame, interactions); - if (info !== undefined) plans.push(info); - } - } - } - return plans; -} - -function projectPlanFrame( - turnId: string, - frame: ToolCallFrame, - interactions: readonly TranscriptInteraction[], -): PlanInfo | undefined { - const toolCallId = frame.toolCallId; - const interaction = interactions.find( - (i) => i.interactionKind === 'approval' && i.toolCallId === toolCallId, - ); - const review = readPlanReview(interaction); - - const requestDisplay = - interaction !== undefined && interaction.request !== null && typeof interaction.request === 'object' - ? (interaction.request as { display?: unknown }).display - : undefined; - const fromInteraction = readPlanReviewDisplay(requestDisplay); - if (fromInteraction !== undefined) { - return { tool_call_id: toolCallId, turn_id: turnId, source: 'interaction', ...fromInteraction, review }; - } - const fromDisplay = readPlanReviewDisplay(frame.display); - if (fromDisplay !== undefined) { - return { tool_call_id: toolCallId, turn_id: turnId, source: 'display', ...fromDisplay, review }; - } - const fromOutput = parsePlanFromOutput(frame.output); - if (fromOutput !== undefined) { - return { tool_call_id: toolCallId, turn_id: turnId, source: 'output', ...fromOutput, review }; - } - return undefined; -} - -function readPlanReview(interaction: TranscriptInteraction | undefined): PlanReviewInfo | undefined { - if (interaction === undefined) return undefined; - const state = interaction.state; - if (state !== 'pending' && state !== 'approved' && state !== 'rejected' && state !== 'cancelled') { - return undefined; - } - const response = - interaction.response !== null && typeof interaction.response === 'object' - ? (interaction.response as { selectedLabel?: unknown; feedback?: unknown }) - : undefined; - const selected = - typeof response?.selectedLabel === 'string' && response.selectedLabel.length > 0 - ? response.selectedLabel - : undefined; - const feedback = - typeof response?.feedback === 'string' && response.feedback.length > 0 - ? response.feedback - : undefined; - return { state, selected_option: selected, feedback }; -} - -function readPlanReviewDisplay(display: unknown): PlanReviewDisplayInfo | undefined { - if (display === null || typeof display !== 'object') return undefined; - const d = display as { kind?: unknown; plan?: unknown; path?: unknown; options?: unknown }; - if (d.kind !== 'plan_review' || typeof d.plan !== 'string' || d.plan.trim().length === 0) { - return undefined; - } - const options = Array.isArray(d.options) - ? d.options - .map((option: unknown): { label: string; description?: string } | null => { - if (option === null || typeof option !== 'object') return null; - const o = option as { label?: unknown; description?: unknown }; - if (typeof o.label !== 'string' || o.label.length === 0) return null; - return { - label: o.label, - description: typeof o.description === 'string' ? o.description : undefined, - }; - }) - .filter((o): o is { label: string; description?: string } => o !== null) - : undefined; - return { - plan: d.plan, - path: typeof d.path === 'string' ? d.path : undefined, - options: options !== undefined && options.length > 0 ? options : undefined, - }; -} - -const PLAN_SAVED_TO_MARKER = 'Plan saved to: '; -const PLAN_BODY_MARKERS = ['## Approved Plan:\n', '## Plan (auto-approved, not user-reviewed):\n']; - -function parsePlanFromOutput(output: unknown): { plan: string; path?: string } | undefined { - if (typeof output !== 'string') return undefined; - let path: string | undefined; - for (const line of output.split('\n')) { - if (line.startsWith(PLAN_SAVED_TO_MARKER)) { - path = line.slice(PLAN_SAVED_TO_MARKER.length).trim() || undefined; - break; - } - } - for (const marker of PLAN_BODY_MARKERS) { - const index = output.indexOf(marker); - if (index === -1) continue; - const plan = output.slice(index + marker.length); - if (plan.trim().length > 0) return { plan, path }; - } - return undefined; -} diff --git a/packages/kap-server/src/search/indexCore.ts b/packages/kap-server/src/search/indexCore.ts index 6b2bd62e963..1e75addaf04 100644 --- a/packages/kap-server/src/search/indexCore.ts +++ b/packages/kap-server/src/search/indexCore.ts @@ -13,7 +13,6 @@ import { import { GlobalSearchError, type GlobalSearchIncomplete } from './contract.ts'; import { - MAX_DOC_TEXT_CHARS, type FileMetaDoc, type MessageDoc, type SearchDoc, @@ -32,7 +31,7 @@ import { type NormalizedQuery, type SearchBudgets, } from './match.ts'; -import { analyzeWireLine, type StepEffect, type TurnEffect } from './wireExtract.ts'; +import { collectWireDocs, initialWireDocCounters, type WireDocCounters } from './wireExtract.ts'; const TEXT_INDEX_NAME = 'body'; const TRI_INDEX_NAME = 'tri'; @@ -80,68 +79,6 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -const INITIAL_TURN_STATE: TurnCounterState = { next: 0, hasTurn: false, openers: [] }; - -function initialTurnState(): TurnCounterState { - return INITIAL_TURN_STATE; -} - -function applyUndoToTurnState(state: TurnCounterState, count: number): TurnCounterState { - let found = 0; - for (let i = state.openers.length - 1; i >= 0; i--) { - if (state.openers[i]!.anchor) { - found++; - if (found === count) { - return { - next: state.openers[i]!.turn, - hasTurn: i > 0, - openers: state.openers.slice(0, i), - }; - } - } - } - return state; -} - -function advanceTurnCounter( - state: TurnCounterState, - effect: TurnEffect, -): { docTurn: number | undefined; state: TurnCounterState } { - switch (effect.kind) { - case 'open': - return { - docTurn: state.next, - state: { - next: state.next + 1, - hasTurn: true, - openers: [...state.openers, { turn: state.next, anchor: effect.anchor }], - }, - }; - case 'ensure': { - const next = state.hasTurn ? state : { ...state, next: state.next + 1, hasTurn: true }; - return { docTurn: next.next - 1, state: next }; - } - case 'undo': - return { docTurn: undefined, state: applyUndoToTurnState(state, effect.count) }; - case 'none': - return { docTurn: undefined, state }; - } -} - -const INITIAL_STEP_STATE: StepTrackerState = { byUuid: {}, begins: 0 }; - -function initialStepState(): StepTrackerState { - return INITIAL_STEP_STATE; -} - -function advanceStepTracker(state: StepTrackerState, effect: StepEffect): StepTrackerState { - if (effect.kind !== 'begin') return state; - const begins = state.begins + 1; - const ordinal = effect.ordinal ?? begins; - if (state.byUuid[effect.uuid] === ordinal) return state; - return { byUuid: { ...state.byUuid, [effect.uuid]: ordinal }, begins }; -} - export interface SearchCoreLog { info(message: string, meta?: Record): void; warn(message: string, meta?: Record): void; @@ -685,8 +622,9 @@ export class SearchIndexCore { } const known = meta?.kind === 'fileMeta' ? meta : undefined; let offset = known?.offset ?? 0; - let turnState: TurnCounterState = known?.turnState ?? initialTurnState(); - let stepState: StepTrackerState = known?.stepState ?? initialStepState(); + const initial = initialWireDocCounters(); + let turnState: TurnCounterState = known?.turnState ?? initial.turnState; + let stepState: StepTrackerState = known?.stepState ?? initial.stepState; const fileMeta = ( nextOffset: number, turns: TurnCounterState, @@ -710,10 +648,10 @@ export class SearchIndexCore { known?.mtimeMs !== undefined && size === known.offset && st.mtimeMs > known.mtimeMs; if (size < offset || legacyMeta || replacedFile || rewrittenInPlace) { this.syncReplaced = true; - await this.deleteFileDocs(db, fileMeta(0, initialTurnState(), initialStepState())); + await this.deleteFileDocs(db, fileMeta(0, initial.turnState, initial.stepState)); offset = 0; - turnState = initialTurnState(); - stepState = initialStepState(); + turnState = initial.turnState; + stepState = initial.stepState; } if (size === offset) { if ( @@ -830,24 +768,11 @@ export class SearchIndexCore { file: WireFileRef, line: string, lineOffset: number, - counters: { turnState: TurnCounterState; stepState: StepTrackerState }, - ): { turnState: TurnCounterState; stepState: StepTrackerState } { - let { turnState, stepState } = counters; - const analysis = analyzeWireLine(line); - const advanced = advanceTurnCounter(turnState, analysis.turn); - if ( - analysis.turn.kind === 'open' || - analysis.turn.kind === 'undo' || - (analysis.turn.kind === 'ensure' && !turnState.hasTurn) - ) { - stepState = initialStepState(); - } - turnState = advanced.state; - stepState = advanceStepTracker(stepState, analysis.step); - const extracted = analysis.messages; - for (let i = 0; i < extracted.length; i++) { - const e = extracted[i]!; - const stepOrdinal = e.stepUuid !== undefined ? stepState.byUuid[e.stepUuid] : undefined; + counters: WireDocCounters, + ): WireDocCounters { + const { counters: next, docs } = collectWireDocs(counters, line); + for (let i = 0; i < docs.length; i++) { + const e = docs[i]!; const doc: MessageDoc = { kind: 'message', sessionId: summary.id, @@ -855,13 +780,10 @@ export class SearchIndexCore { sessionTitle: summary.title ?? '', agentId: file.agentId, role: e.role, - text: e.text.length > MAX_DOC_TEXT_CHARS ? e.text.slice(0, MAX_DOC_TEXT_CHARS) : e.text, + text: e.text, time: e.time ?? summary.updatedAt, - turn: advanced.docTurn, - stepId: - advanced.docTurn !== undefined && stepOrdinal !== undefined - ? `t${advanced.docTurn}.${stepOrdinal}` - : undefined, + turn: e.turn, + stepId: e.stepId, }; ops.push({ op: 'set', @@ -869,7 +791,7 @@ export class SearchIndexCore { value: doc, }); } - return { turnState, stepState }; + return next; } async search(params: CoreSearchParams): Promise { diff --git a/packages/kap-server/src/search/liveSource.ts b/packages/kap-server/src/search/liveSource.ts new file mode 100644 index 00000000000..7fe29d51116 --- /dev/null +++ b/packages/kap-server/src/search/liveSource.ts @@ -0,0 +1,140 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +import { + followSessionLifecycles, + getLiveSessionById, + IAgentLifecycleService, + ISessionIndex, + IWireService, + MAIN_AGENT_ID, + type Scope, +} from '@moonshot-ai/agent-core-v2'; + +import type { + LiveTranscriptSource, + LiveTranscriptView, + LiveWireDoc, +} from './searchService'; +import { collectWireDocs, initialWireDocCounters, type WireDocCounters } from './wireExtract'; + +export interface LocalLiveTranscriptSourceDeps { + readonly homeDir: string; + readonly core: Scope; +} + +interface LiveEntry { + readonly view: LocalLiveView; + readonly ready: Promise; +} + +class LocalLiveView implements LiveTranscriptView { + roster: readonly { agentId: string }[] = []; + readonly byAgent = new Map(); + + agents(): readonly { agentId: string }[] { + return this.roster; + } + + docs(agentId: string): readonly LiveWireDoc[] | undefined { + return this.byAgent.get(agentId); + } +} + +export class LocalLiveTranscriptSource implements LiveTranscriptSource { + private readonly live = new Map(); + + constructor(private readonly deps: LocalLiveTranscriptSourceDeps) { + followSessionLifecycles(deps.core.accessor, (service) => { + const d1 = service.onDidCloseSession(({ sessionId }) => this.live.delete(sessionId)); + const d2 = service.onDidArchiveSession(({ sessionId }) => this.live.delete(sessionId)); + return { + dispose: () => { + d1.dispose(); + d2.dispose(); + }, + }; + }); + } + + forSessionLive(sessionId: string): LiveTranscriptView | undefined { + const existing = this.live.get(sessionId); + if (existing !== undefined) { + if (getLiveSessionById(this.deps.core.accessor, sessionId) !== undefined) { + return existing.view; + } + this.live.delete(sessionId); + return undefined; + } + if (getLiveSessionById(this.deps.core.accessor, sessionId) === undefined) return undefined; + const view = new LocalLiveView(); + const entry: LiveEntry = { view, ready: this.loadRoster(sessionId, view) }; + this.live.set(sessionId, entry); + return view; + } + + async whenReady(sessionId: string): Promise { + await this.live.get(sessionId)?.ready; + } + + async ensureAgentHistory(sessionId: string, agentId: string): Promise { + const entry = this.live.get(sessionId); + if (entry === undefined) return; + await entry.ready; + const docs = await this.readAgentDocs(sessionId, agentId); + if (this.live.get(sessionId) !== entry) return; + entry.view.byAgent.set(agentId, docs); + if (!entry.view.roster.some((agent) => agent.agentId === agentId)) { + entry.view.roster = [...entry.view.roster, { agentId }]; + } + } + + private async loadRoster(sessionId: string, view: LocalLiveView): Promise { + const dir = await this.sessionDir(sessionId); + const roster: { agentId: string }[] = [{ agentId: MAIN_AGENT_ID }]; + if (dir !== undefined) { + const agentsDir = join(dir, 'agents'); + try { + const entries = await readdir(agentsDir, { recursive: true, withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || entry.name !== 'wire.jsonl') continue; + const agentId = relative(agentsDir, entry.parentPath); + if (agentId !== MAIN_AGENT_ID && !roster.some((agent) => agent.agentId === agentId)) { + roster.push({ agentId }); + } + } + } catch { + } + } + view.roster = roster; + } + + private async readAgentDocs(sessionId: string, agentId: string): Promise { + const session = getLiveSessionById(this.deps.core.accessor, sessionId); + const handle = session?.accessor.get(IAgentLifecycleService).handleOf(agentId); + if (handle !== undefined) await handle.accessor.get(IWireService).flush(); + const dir = await this.sessionDir(sessionId); + if (dir === undefined) return []; + let text: string; + try { + text = await readFile(join(dir, 'agents', agentId, 'wire.jsonl'), 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + let counters: WireDocCounters = initialWireDocCounters(); + const docs: LiveWireDoc[] = []; + for (const line of text.split('\n')) { + const out = collectWireDocs(counters, line); + counters = out.counters; + docs.push(...out.docs); + } + return docs; + } + + private async sessionDir(sessionId: string): Promise { + const summary = await this.deps.core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return undefined; + return join(this.deps.homeDir, 'sessions', summary.workspaceId, sessionId); + } +} diff --git a/packages/kap-server/src/search/searchService.ts b/packages/kap-server/src/search/searchService.ts index d8e755a2241..fdd0aa9228c 100644 --- a/packages/kap-server/src/search/searchService.ts +++ b/packages/kap-server/src/search/searchService.ts @@ -17,7 +17,6 @@ import { type SessionSummary, } from '@moonshot-ai/agent-core-v2'; import { normalizeLiteral, tokenize } from '@moonshot-ai/minidb'; -import type { TranscriptStore } from '@moonshot-ai/transcript'; import { GlobalSearchError, @@ -103,8 +102,21 @@ export interface IGlobalSearchService { export const IGlobalSearchService = createDecorator('globalSearch'); +export interface LiveWireDoc { + readonly role: 'user' | 'assistant'; + readonly text: string; + readonly time?: number; + readonly turn?: number; + readonly stepId?: string; +} + +export interface LiveTranscriptView { + agents(): readonly { agentId: string }[]; + docs(agentId: string): readonly LiveWireDoc[] | undefined; +} + export interface LiveTranscriptSource { - forSessionLive(sessionId: string): TranscriptStore | undefined; + forSessionLive(sessionId: string): LiveTranscriptView | undefined; whenReady(sessionId: string): Promise; ensureAgentHistory(sessionId: string, agentId: string): Promise; } @@ -394,9 +406,9 @@ export class GlobalSearchService implements IGlobalSearchService { async search(input: GlobalSearchQuery): Promise { const q = normalizeQuery(input, this.maxQueryTerms); const sessionId = q.container?.sessionId; - const liveStore = sessionId !== undefined ? this.liveSource?.forSessionLive(sessionId) : undefined; - if (liveStore !== undefined && sessionId !== undefined) { - return this.searchLive(q, sessionId, liveStore, input.pageToken); + const liveView = sessionId !== undefined ? this.liveSource?.forSessionLive(sessionId) : undefined; + if (liveView !== undefined && sessionId !== undefined) { + return this.searchLive(q, sessionId, liveView, input.pageToken); } return this.searchIndex(q, input.pageToken); } @@ -404,7 +416,7 @@ export class GlobalSearchService implements IGlobalSearchService { private async searchLive( q: NormalizedQuery, sessionId: string, - store: TranscriptStore, + view: LiveTranscriptView, pageToken: string | undefined, ): Promise { const page = decodePageToken(q, 'live', pageToken, undefined); @@ -416,11 +428,11 @@ export class GlobalSearchService implements IGlobalSearchService { const agentIds = q.container?.agentId !== undefined ? [q.container.agentId] - : store.agents().map((agent) => agent.agentId); + : view.agents().map((agent) => agent.agentId); for (const agentId of agentIds) { await source.ensureAgentHistory(sessionId, agentId); } - const docs = await this.collectLiveDocs(sessionId, store, agentIds); + const docs = await this.collectLiveDocs(sessionId, view, agentIds); const budget = { deadlineAt: Date.now() + this.queryDeadlineMs, textCharsLeft: this.queryTextBudgetChars, @@ -455,66 +467,35 @@ export class GlobalSearchService implements IGlobalSearchService { private async collectLiveDocs( sessionId: string, - store: TranscriptStore, + view: LiveTranscriptView, agentIds: readonly string[], ): Promise<{ key: string; value: MessageDoc | TitleDoc }[]> { const summary = await this.sessionIndex.get(sessionId); const workspaceId = summary?.workspaceId ?? ''; const sessionTitle = summary?.title ?? ''; const fallbackTime = summary?.updatedAt ?? 0; - const parseTime = (iso: string | undefined): number => { - if (iso === undefined) return fallbackTime; - const ms = Date.parse(iso); - return Number.isNaN(ms) ? fallbackTime : ms; - }; const docs: { key: string; value: MessageDoc | TitleDoc }[] = []; for (const agentId of agentIds) { - const transcript = store.getAgent(agentId); - if (transcript === undefined) continue; - for (const item of transcript.snapshot().items) { - if (item.kind !== 'turn') continue; - const turnTime = parseTime(item.startedAt); - const prompt = item.prompt?.trim() ?? ''; - if (prompt.length > 0) { - docs.push({ - key: `${sessionId}/${agentId}/live/u/t${item.ordinal}`, - value: { - kind: 'message', - sessionId, - workspaceId, - sessionTitle, - agentId, - role: 'user', - text: prompt.length > MAX_DOC_TEXT_CHARS ? prompt.slice(0, MAX_DOC_TEXT_CHARS) : prompt, - time: turnTime, - turn: item.ordinal, - stepId: undefined, - }, - }); - } - for (const step of item.steps) { - const stepTime = parseTime(step.endedAt ?? step.startedAt ?? item.startedAt); - for (const frame of step.frames) { - if (frame.kind !== 'text' || frame.role !== 'assistant') continue; - const text = frame.text.trim(); - if (text.length === 0) continue; - docs.push({ - key: `${sessionId}/${agentId}/live/a/${frame.frameId}`, - value: { - kind: 'message', - sessionId, - workspaceId, - sessionTitle, - agentId, - role: 'assistant', - text: text.length > MAX_DOC_TEXT_CHARS ? text.slice(0, MAX_DOC_TEXT_CHARS) : text, - time: stepTime, - turn: item.ordinal, - stepId: step.stepId, - }, - }); - } - } + const liveDocs = view.docs(agentId); + if (liveDocs === undefined) continue; + for (const liveDoc of liveDocs) { + const text = liveDoc.text.trim(); + if (text.length === 0) continue; + docs.push({ + key: `${sessionId}/${agentId}/live/${docs.length}`, + value: { + kind: 'message', + sessionId, + workspaceId, + sessionTitle, + agentId, + role: liveDoc.role, + text: text.length > MAX_DOC_TEXT_CHARS ? text.slice(0, MAX_DOC_TEXT_CHARS) : text, + time: liveDoc.time ?? fallbackTime, + turn: liveDoc.turn, + stepId: liveDoc.stepId, + }, + }); } } if (sessionTitle.length > 0) { diff --git a/packages/kap-server/src/search/wireExtract.ts b/packages/kap-server/src/search/wireExtract.ts index 24d1bec8e95..dc7e81b7803 100644 --- a/packages/kap-server/src/search/wireExtract.ts +++ b/packages/kap-server/src/search/wireExtract.ts @@ -1,5 +1,7 @@ import { matchSingleMediaPathTag } from '@moonshot-ai/agent-core-v2/agent/media/mediaRef'; +import type { StepTrackerState, TurnCounterState } from './docs'; + export interface ExtractedWireMessage { readonly role: 'user' | 'assistant'; readonly text: string; @@ -226,3 +228,105 @@ export function analyzeWireLine(line: string): WireLineAnalysis { export function extractFromWireLine(line: string): ExtractedWireMessage[] { return analyzeWireLine(line).messages; } + +export interface WireDocDraft { + readonly role: 'user' | 'assistant'; + readonly text: string; + readonly time?: number; + readonly turn?: number; + readonly stepId?: string; +} + +export interface WireDocCounters { + readonly turnState: TurnCounterState; + readonly stepState: StepTrackerState; +} + +const INITIAL_TURN_STATE: TurnCounterState = { next: 0, hasTurn: false, openers: [] }; + +const INITIAL_STEP_STATE: StepTrackerState = { byUuid: {}, begins: 0 }; + +export function initialWireDocCounters(): WireDocCounters { + return { turnState: INITIAL_TURN_STATE, stepState: INITIAL_STEP_STATE }; +} + +export function collectWireDocs( + counters: WireDocCounters, + line: string, +): { counters: WireDocCounters; docs: WireDocDraft[] } { + const analysis = analyzeWireLine(line); + const advanced = advanceTurnCounter(counters.turnState, analysis.turn); + const resetSteps = + analysis.turn.kind === 'open' || + analysis.turn.kind === 'undo' || + (analysis.turn.kind === 'ensure' && !counters.turnState.hasTurn); + const stepState = advanceStepTracker( + resetSteps ? INITIAL_STEP_STATE : counters.stepState, + analysis.step, + ); + const docs: WireDocDraft[] = []; + for (const e of analysis.messages) { + const stepOrdinal = e.stepUuid !== undefined ? stepState.byUuid[e.stepUuid] : undefined; + docs.push({ + role: e.role, + text: e.text, + time: e.time, + turn: advanced.docTurn, + stepId: + advanced.docTurn !== undefined && stepOrdinal !== undefined + ? `t${advanced.docTurn}.${stepOrdinal}` + : undefined, + }); + } + return { counters: { turnState: advanced.state, stepState }, docs }; +} + +function applyUndoToTurnState(state: TurnCounterState, count: number): TurnCounterState { + let found = 0; + for (let i = state.openers.length - 1; i >= 0; i--) { + if (state.openers[i]!.anchor) { + found++; + if (found === count) { + return { + next: state.openers[i]!.turn, + hasTurn: i > 0, + openers: state.openers.slice(0, i), + }; + } + } + } + return state; +} + +function advanceTurnCounter( + state: TurnCounterState, + effect: TurnEffect, +): { docTurn: number | undefined; state: TurnCounterState } { + switch (effect.kind) { + case 'open': + return { + docTurn: state.next, + state: { + next: state.next + 1, + hasTurn: true, + openers: [...state.openers, { turn: state.next, anchor: effect.anchor }], + }, + }; + case 'ensure': { + const next = state.hasTurn ? state : { ...state, next: state.next + 1, hasTurn: true }; + return { docTurn: next.next - 1, state: next }; + } + case 'undo': + return { docTurn: undefined, state: applyUndoToTurnState(state, effect.count) }; + case 'none': + return { docTurn: undefined, state }; + } +} + +function advanceStepTracker(state: StepTrackerState, effect: StepEffect): StepTrackerState { + if (effect.kind !== 'begin') return state; + const begins = state.begins + 1; + const ordinal = effect.ordinal ?? begins; + if (state.byUuid[effect.uuid] === ordinal) return state; + return { byUuid: { ...state.byUuid, [effect.uuid]: ordinal }, begins }; +} diff --git a/packages/kap-server/src/services/legacyStatus/legacyActivity.ts b/packages/kap-server/src/services/legacyStatus/legacyActivity.ts deleted file mode 100644 index 4178718f714..00000000000 --- a/packages/kap-server/src/services/legacyStatus/legacyActivity.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { - INTERACTION_TAG_AGENT_ID, - interactions, - type AgentActivitySnapshot, - type IAgentScopeHandle, -} from '@moonshot-ai/agent-core-v2'; - -import { - toLegacyPhase, - type AgentPhase, - type LegacyActivityApproval, - type LegacyActivityLastTurn, -} from './legacyStatus'; - -export function legacyApprovalsOf(handle: IAgentScopeHandle): readonly LegacyActivityApproval[] { - return interactions - .findAll({ kind: 'approval', resolved: false, tags: { [INTERACTION_TAG_AGENT_ID]: handle.id } }) - .map((interaction) => ({ - approvalId: interaction.id, - toolCallId: (interaction.payload as { toolCallId?: string }).toolCallId ?? '', - since: interaction.createdAt, - })); -} - -export class LegacyActivityTracker { - private readonly toolSince = new Map(); - private lastTurn: LegacyActivityLastTurn | undefined; - private lastPhaseKey: string | undefined; - - constructor( - private readonly readSnapshot: () => AgentActivitySnapshot, - private readonly readApprovals: () => readonly LegacyActivityApproval[], - ) {} - - toolStarted(toolCallId: string): void { - this.toolSince.set(toolCallId, Date.now()); - } - - toolResult(toolCallId: string): void { - this.toolSince.delete(toolCallId); - } - - interrupted(event: { readonly turnId: number; readonly step: number; readonly reason: string }): AgentPhase | undefined { - if (event.reason !== 'aborted' && event.reason !== 'max_steps' && event.reason !== 'error') { - return undefined; - } - return this.emit({ - kind: 'interrupted', - turnId: event.turnId, - step: event.step, - reason: event.reason, - at: Date.now(), - }); - } - - turnEnded(event: { - readonly turnId: number; - readonly reason: LegacyActivityLastTurn['reason']; - readonly durationMs?: number; - }): AgentPhase | undefined { - this.toolSince.clear(); - this.lastTurn = { - turnId: event.turnId, - reason: event.reason, - durationMs: event.durationMs, - at: Date.now(), - }; - return this.emit({ - kind: 'ended', - turnId: event.turnId, - reason: event.reason, - durationMs: event.durationMs, - at: this.lastTurn.at, - }); - } - - recompute(): AgentPhase | undefined { - const snapshot = this.readSnapshot(); - const turn = snapshot.turn; - return this.emit( - toLegacyPhase({ - turn: - turn === undefined - ? undefined - : { - turnId: turn.turnId, - phase: turn.phase, - step: turn.step, - ending: turn.ending, - endingReason: turn.endingReason, - retry: turn.retry, - pendingApprovals: this.readApprovals(), - activeToolCalls: turn.activeToolCalls.map((call) => ({ - toolCallId: call.toolCallId, - name: call.name, - since: this.toolSince.get(call.toolCallId) ?? turn.since ?? Date.now(), - })), - since: turn.since ?? Date.now(), - }, - lastTurn: this.lastTurn, - }), - ); - } - - private emit(phase: AgentPhase | undefined): AgentPhase | undefined { - if (phase === undefined) return undefined; - const key = JSON.stringify(phase); - if (key === this.lastPhaseKey) return undefined; - this.lastPhaseKey = key; - return phase; - } -} - -export function phaseFromDomainEvent( - tracker: LegacyActivityTracker, - event: { readonly type: string; readonly toolCallId?: string; readonly turnId?: number; readonly step?: number; readonly reason?: string; readonly durationMs?: number }, -): AgentPhase | undefined { - switch (event.type) { - case 'turn.started': - case 'turn.step.started': - case 'turn.step.retrying': - case 'permission.approval.requested': - case 'permission.approval.resolved': - return tracker.recompute(); - case 'tool.call.started': - if (event.toolCallId !== undefined) tracker.toolStarted(event.toolCallId); - return tracker.recompute(); - case 'tool.result': - if (event.toolCallId !== undefined) tracker.toolResult(event.toolCallId); - return tracker.recompute(); - case 'turn.step.interrupted': - if (event.turnId === undefined || event.step === undefined || event.reason === undefined) { - return undefined; - } - return tracker.interrupted({ turnId: event.turnId, step: event.step, reason: event.reason }); - case 'turn.ended': - if (event.turnId === undefined || event.reason === undefined) return undefined; - return tracker.turnEnded({ - turnId: event.turnId, - reason: event.reason as LegacyActivityLastTurn['reason'], - durationMs: event.durationMs, - }); - default: - return undefined; - } -} diff --git a/packages/kap-server/src/services/legacyStatus/legacyStatus.ts b/packages/kap-server/src/services/legacyStatus/legacyStatus.ts index 60d3fce2a0d..fdc5185a1ca 100644 --- a/packages/kap-server/src/services/legacyStatus/legacyStatus.ts +++ b/packages/kap-server/src/services/legacyStatus/legacyStatus.ts @@ -8,105 +8,6 @@ import { type IAgentScopeHandle, type UsageStatus, } from '@moonshot-ai/agent-core-v2'; -import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; - -export type AgentPhase = - | { readonly kind: 'idle' } - | { - readonly kind: 'running'; - readonly turnId: number; - readonly step: number; - readonly stepId: string; - readonly since: number; - } - | { - readonly kind: 'tool_call'; - readonly turnId: number; - readonly step: number; - readonly toolCallId: string; - readonly name: string; - readonly since: number; - } - | { - readonly kind: 'retrying'; - readonly turnId: number; - readonly step: number; - readonly stepId: string; - readonly failedAttempt: number; - readonly nextAttempt: number; - readonly maxAttempts: number; - readonly delayMs: number; - readonly errorName?: string; - readonly statusCode?: number; - readonly since: number; - } - | { - readonly kind: 'awaiting_approval'; - readonly turnId: number; - readonly step?: number; - readonly approval?: unknown; - readonly since: number; - } - | { - readonly kind: 'interrupted'; - readonly turnId: number; - readonly step?: number; - readonly reason: 'aborted' | 'max_steps' | 'error'; - readonly message?: string; - readonly at: number; - } - | { - readonly kind: 'ended'; - readonly turnId: number; - readonly reason: TurnEndReason; - readonly durationMs?: number; - readonly at: number; - }; - -export interface LegacyActivityApproval { - readonly approvalId: string; - readonly toolCallId: string; - readonly since: number; -} - -export interface LegacyActivityToolCall { - readonly toolCallId: string; - readonly name: string; - readonly since: number; -} - -export interface LegacyActivityRetry { - readonly failedAttempt: number; - readonly nextAttempt: number; - readonly maxAttempts: number; - readonly delayMs: number; - readonly errorName?: string; - readonly statusCode?: number; -} - -export interface LegacyActivityTurn { - readonly turnId: number; - readonly phase: 'running' | 'tool_call' | 'retrying'; - readonly step: number; - readonly ending: boolean; - readonly endingReason?: 'aborted' | 'max_steps' | 'error'; - readonly retry?: LegacyActivityRetry; - readonly pendingApprovals: readonly LegacyActivityApproval[]; - readonly activeToolCalls: readonly LegacyActivityToolCall[]; - readonly since: number; -} - -export interface LegacyActivityLastTurn { - readonly turnId: number; - readonly reason: TurnEndReason; - readonly durationMs?: number; - readonly at: number; -} - -export interface LegacyActivitySnapshot { - readonly turn?: LegacyActivityTurn; - readonly lastTurn?: LegacyActivityLastTurn; -} export interface LegacyStatusSnapshot { readonly usage?: UsageStatus; @@ -159,75 +60,3 @@ function defaultModelContextTokens(agent: IAgentScopeHandle): number | undefined return undefined; } } - -export function toLegacyPhase(state: LegacyActivitySnapshot): AgentPhase | undefined { - const { turn, lastTurn } = state; - - if (turn === undefined) { - if (lastTurn !== undefined) { - return { - kind: 'ended', - turnId: lastTurn.turnId, - reason: lastTurn.reason, - durationMs: lastTurn.durationMs, - at: lastTurn.at, - }; - } - return { kind: 'idle' }; - } - - if (turn.pendingApprovals.length > 0) { - const latest = turn.pendingApprovals[turn.pendingApprovals.length - 1]!; - return { - kind: 'awaiting_approval', - turnId: turn.turnId, - step: turn.step || undefined, - approval: { approvalId: latest.approvalId, toolCallId: latest.toolCallId }, - since: latest.since, - }; - } - if (turn.ending && turn.endingReason !== undefined) { - return { - kind: 'interrupted', - turnId: turn.turnId, - step: turn.step, - reason: turn.endingReason, - at: turn.since, - }; - } - switch (turn.phase) { - case 'running': - return { - kind: 'running', - turnId: turn.turnId, - step: turn.step, - stepId: '', - since: turn.since, - }; - case 'retrying': - return { - kind: 'retrying', - turnId: turn.turnId, - step: turn.step, - stepId: '', - failedAttempt: turn.retry?.failedAttempt ?? 0, - nextAttempt: turn.retry?.nextAttempt ?? 0, - maxAttempts: turn.retry?.maxAttempts ?? 0, - delayMs: turn.retry?.delayMs ?? 0, - errorName: turn.retry?.errorName, - statusCode: turn.retry?.statusCode, - since: turn.since, - }; - case 'tool_call': { - const latest = turn.activeToolCalls[turn.activeToolCalls.length - 1]; - return { - kind: 'tool_call', - turnId: turn.turnId, - step: turn.step, - toolCallId: latest?.toolCallId ?? '', - name: latest?.name ?? '', - since: latest?.since ?? turn.since, - }; - } - } -} diff --git a/packages/kap-server/src/services/messages/messageHistory.ts b/packages/kap-server/src/services/messages/messageHistory.ts deleted file mode 100644 index 0e99d0ffe5f..00000000000 --- a/packages/kap-server/src/services/messages/messageHistory.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { - AGENT_WIRE_RECORD_KEY, - IAgentBlobService, - IAgentContextMemoryService, - IAgentScopeContext, - IAppendLogStore, - ISessionIndex, - IWireService, - createContextTranscriptReducer, - resumeSessionById, - type ContextMessage, - type ContextTranscript, - type IAgentScopeHandle, - type Scope, - type WireRecord, -} from '@moonshot-ai/agent-core-v2'; - -import { ensureMainAgent } from '../../transport/mainAgent'; -import type { Message, MessageRole } from '../../protocol/message'; -import { toProtocolMessage } from './messageProjection'; - -const DEFAULT_PAGE_SIZE = 50; -const MAX_PAGE_SIZE = 100; - -export class SessionNotFoundError extends Error { - readonly sessionId: string; - constructor(sessionId: string) { - super(`session ${sessionId} does not exist`); - this.name = 'SessionNotFoundError'; - this.sessionId = sessionId; - } -} - -export class MessageNotFoundError extends Error { - readonly sessionId: string; - readonly messageId: string; - constructor(sessionId: string, messageId: string) { - super(`message ${messageId} does not exist in session ${sessionId}`); - this.name = 'MessageNotFoundError'; - this.sessionId = sessionId; - this.messageId = messageId; - } -} - -export interface MessageListQuery { - readonly before_id?: string | undefined; - readonly after_id?: string | undefined; - readonly page_size?: number | undefined; - readonly role?: MessageRole | undefined; -} - -export interface PageResponse { - items: T[]; - has_more: boolean; -} - -export async function listMessages( - core: Scope, - sessionId: string, - query: MessageListQuery, -): Promise> { - const all = await loadMessages(core, sessionId); - const desc = [...all].reverse(); - - let pivotIndex = -1; - if (query.before_id !== undefined) { - pivotIndex = desc.findIndex((m) => m.id === query.before_id); - } else if (query.after_id !== undefined) { - pivotIndex = desc.findIndex((m) => m.id === query.after_id); - } - - let slice: Message[]; - if (query.before_id !== undefined && pivotIndex >= 0) { - slice = desc.slice(pivotIndex + 1); - } else if (query.after_id !== undefined && pivotIndex >= 0) { - slice = desc.slice(0, pivotIndex); - } else { - slice = desc; - } - - const requestedSize = query.page_size ?? DEFAULT_PAGE_SIZE; - const pageSize = Math.min(Math.max(requestedSize, 1), MAX_PAGE_SIZE); - const page = slice.slice(0, pageSize); - const hasMore = slice.length > pageSize; - - const filtered = query.role !== undefined ? page.filter((m) => m.role === query.role) : page; - - return { items: filtered, has_more: hasMore }; -} - -export async function getMessage( - core: Scope, - sessionId: string, - messageId: string, -): Promise { - const all = await loadMessages(core, sessionId); - const entry = all.find((m) => m.id === messageId); - if (entry === undefined) { - throw new MessageNotFoundError(sessionId, messageId); - } - return entry; -} - -async function loadMessages(core: Scope, sessionId: string): Promise { - const summary = await core.accessor.get(ISessionIndex).get(sessionId); - if (summary === undefined) { - throw new SessionNotFoundError(sessionId); - } - - const session = await resumeSessionById(core.accessor, sessionId); - if (session === undefined) return []; - const agent = await ensureMainAgent(session); - - return loadMessageHistory(core, agent, sessionId, summary.createdAt); -} - -export async function loadMessageHistory( - core: Scope, - agent: IAgentScopeHandle, - sessionId: string, - sessionCreatedAtMs: number, -): Promise { - const transcript = await readTranscript(core, agent); - const contextMessages = agent.accessor.get(IAgentContextMemoryService).get(); - const merged = mergeLiveTail(transcript, contextMessages); - const entries = await rehydrate(agent, merged.messages); - - let previousMs = Number.NEGATIVE_INFINITY; - return entries.map((msg, index) => { - const baseMs = merged.times[index] ?? sessionCreatedAtMs + index; - const createdAtMs = Math.max(previousMs + 1, baseMs); - previousMs = createdAtMs; - return toProtocolMessage(sessionId, index, msg, sessionCreatedAtMs, createdAtMs); - }); -} - -async function rehydrate( - agent: IAgentScopeHandle, - messages: readonly ContextMessage[], -): Promise { - const blobs = agent.accessor.get(IAgentBlobService); - let changed = false; - const out: ContextMessage[] = []; - for (const msg of messages) { - const content = await blobs.loadParts(msg.content); - if (content === msg.content) { - out.push(msg); - continue; - } - changed = true; - out.push({ ...msg, content: [...content] }); - } - return changed ? out : messages; -} - -async function readTranscript(core: Scope, agent: IAgentScopeHandle): Promise { - await agent.accessor.get(IWireService).flush(); - const scope = agent.accessor.get(IAgentScopeContext).scope(); - const reducer = createContextTranscriptReducer(); - for await (const record of core.accessor - .get(IAppendLogStore) - .read(scope, AGENT_WIRE_RECORD_KEY)) { - reducer.add(record); - } - return reducer.result(); -} - -function mergeLiveTail( - transcript: ContextTranscript, - contextMessages: readonly ContextMessage[], -): { - readonly messages: readonly ContextMessage[]; - readonly times: readonly (number | undefined)[]; -} { - if (contextMessages.length <= transcript.foldedLength) { - return { messages: transcript.entries, times: transcript.times }; - } - const tail = contextMessages.slice(transcript.foldedLength); - return { - messages: [...transcript.entries, ...tail], - times: [...transcript.times, ...tail.map(() => undefined)], - }; -} diff --git a/packages/kap-server/src/services/transcript/coreBinding.ts b/packages/kap-server/src/services/transcript/coreBinding.ts deleted file mode 100644 index 4f8164a81ab..00000000000 --- a/packages/kap-server/src/services/transcript/coreBinding.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { - IAgentLifecycleService, - IAgentLoopService, - IAgentPromptService, - IAgentScopeContext, - IAgentTaskService, - IEventBus, - INTERACTION_TAG_AGENT_ID, - INTERACTION_TAG_SESSION_ID, - ISessionMetadata, - MAIN_AGENT_ID, - interactions, - toDisposable, - type AgentMeta, - type IDisposable, - type IAgentScopeHandle, - type Interaction, - type ISessionScopeHandle, -} from '@moonshot-ai/agent-core-v2'; -import type { AgentDescriptor, TranscriptChangeEvent, TranscriptStore } from '@moonshot-ai/transcript'; - -import { legacyApprovalsOf } from '../legacyStatus/legacyActivity'; -import { - AgentTranscriptProjector, - type ProjectorBusEvent, - type ProjectorInteraction, -} from './coreEventMap'; - -export interface TranscriptBindingLogger { - warn(obj: unknown, msg: string): void; -} - -export interface TranscriptBinding extends IDisposable { - seedPendingInteractions(agentId?: string): void; -} - -export function bindSessionTranscript( - store: TranscriptStore, - session: ISessionScopeHandle, - logger?: TranscriptBindingLogger, - onOps?: (event: TranscriptChangeEvent) => void, -): TranscriptBinding { - const agents = session.accessor.get(IAgentLifecycleService); - const pendingInteractions = (): readonly Interaction[] => - interactions.findAll({ - resolved: false, - tags: { [INTERACTION_TAG_SESSION_ID]: session.id }, - }); - const disposables: IDisposable[] = []; - const agentDisposables = new Map(); - const subscribedAgents = new Set(); - const projectors = new Map(); - const interactionAgents = new Map(); - const knownInteractions = new Set(); - const unseeded = new Map(); - const earlyResolves = new Map(); - const seededAgents = new Set(); - let seededAll = false; - const isSeeded = (agentId: string): boolean => seededAll || seededAgents.has(agentId); - - const applyOps = (agentId: string, ops: ReturnType): void => { - if (ops.length === 0) return; - const result = store.ensureAgent(agentId).apply(ops); - if (result.gap !== undefined) { - logger?.warn( - { sessionId: store.sessionId, agentId, gap: result.gap }, - 'transcript: append gap — producer/consumer skew', - ); - return; - } - onOps?.({ agentId, ops }); - }; - - const projectorFor = (agentId: string): AgentTranscriptProjector => { - let projector = projectors.get(agentId); - if (projector === undefined) { - projector = new AgentTranscriptProjector(agentId, store.sessionId, { - stepFrames: (turnId, stepId) => - store.getAgent(agentId)?.getTurn(turnId)?.steps.find((s) => s.stepId === stepId)?.frames, - toolFrame: (toolCallId) => { - const transcript = store.getAgent(agentId); - if (transcript === undefined) return undefined; - for (const item of transcript.getItems()) { - if (item.kind !== 'turn') continue; - for (const step of item.steps) { - for (const frame of step.frames) { - if (frame.kind === 'tool' && frame.toolCallId === toolCallId) { - return { turnId: item.turnId, stepId: step.stepId, frame }; - } - } - } - } - return undefined; - }, - stepOrdinal: (turnId) => { - const agentHandle = agents.handleOf(agentId); - if (agentHandle === undefined) return undefined; - const turn = agentHandle.accessor.get(IAgentLoopService)?.activitySnapshot().turn; - return turn === undefined || `t${turn.turnId}` !== turnId ? undefined : turn.step; - }, - activitySnapshot: () => - agents.handleOf(agentId)?.accessor.get(IAgentLoopService)?.activitySnapshot() ?? {}, - pendingApprovals: () => { - const agentHandle = agents.handleOf(agentId); - return agentHandle === undefined ? [] : legacyApprovalsOf(agentHandle); - }, - turn: (turnId) => store.getAgent(agentId)?.getTurn(turnId), - items: () => store.getAgent(agentId)?.getItems(), - resolvePlanRevisionKey: (key) => - agents.handleOf(agentId)?.accessor.get(IAgentScopeContext).scope(key) ?? key, - }); - const agentHandle = agents.handleOf(agentId); - if (agentHandle !== undefined) { - const tasks = agentHandle.accessor.get(IAgentTaskService)?.list() ?? []; - for (const info of tasks) { - if (info.kind === 'agent' && typeof info.agentId === 'string' && info.agentId.length > 0) { - applyOps( - agentId, - projector.seedSubagentTask({ - taskId: info.taskId, - agentId: info.agentId, - description: info.description, - status: info.status, - detached: info.detached ?? false, - startedAt: info.startedAt, - }), - ); - } - } - } - projectors.set(agentId, projector); - } - return projector; - }; - - const subscribeAgent = (handle: IAgentScopeHandle): void => { - if (subscribedAgents.has(handle.id)) return; - subscribedAgents.add(handle.id); - const projector = projectorFor(handle.id); - store.ensureAgent(handle.id, { agentId: handle.id }); - const bus = handle.accessor.get(IEventBus); - const busD = bus.subscribe((event) => - applyOps(handle.id, projector.map(event as ProjectorBusEvent)), - ); - const loopStatus = handle.accessor.get(IAgentLoopService)?.status(); - if (loopStatus?.state === 'running' && loopStatus.activeTurnId !== undefined) { - const promptId = handle.accessor.get(IAgentPromptService)?.list().active?.id; - projector.seedActiveTurn({ turnId: loopStatus.activeTurnId, promptId }); - } - const list = agentDisposables.get(handle.id) ?? []; - list.push(busD); - agentDisposables.set(handle.id, list); - }; - - const interactionAgentId = (interaction: Interaction): string => { - const payloadAgent = (interaction.payload as { agentId?: unknown }).agentId; - const tag = interaction.tags[INTERACTION_TAG_AGENT_ID]; - return ( - (typeof tag === 'string' ? tag : undefined) ?? - (typeof payloadAgent === 'string' ? payloadAgent : undefined) ?? - MAIN_AGENT_ID - ); - }; - - const announceInteraction = (interaction: Interaction): void => { - if (interaction.kind !== 'approval' && interaction.kind !== 'question') return; - const agentId = interactionAgentId(interaction); - interactionAgents.set(interaction.id, agentId); - const request: ProjectorInteraction = { - id: interaction.id, - kind: interaction.kind, - payload: interaction.payload, - createdAt: interaction.createdAt, - }; - applyOps(agentId, projectorFor(agentId).mapInteractionRequested(request)); - }; - - const refreshDescriptors = (): void => { - void session.accessor - .get(ISessionMetadata) - .read() - .then((meta) => { - for (const agentId of projectors.keys()) { - store.describeAgent(descriptorFromMeta(agentId, meta.agents?.[agentId])); - } - }) - .catch(() => { - }); - }; - - for (const agent of agents.list()) { - const handle = agents.handleOf(agent.agentId); - if (handle !== undefined) subscribeAgent(handle); - } - disposables.push( - agents.onDidCreate((context) => { - const handle = agents.handleOf(context.agentId); - if (handle !== undefined) subscribeAgent(handle); - seededAgents.add(context.agentId); - refreshDescriptors(); - }), - agents.onDidClose((context) => { - const agentId = context.agentId; - for (const d of agentDisposables.get(agentId) ?? []) d.dispose(); - agentDisposables.delete(agentId); - subscribedAgents.delete(agentId); - projectors.delete(agentId); - store.markDisposed(agentId, new Date().toISOString()); - }), - ); - - for (const pending of pendingInteractions()) { - if (pending.kind !== 'approval' && pending.kind !== 'question') continue; - if (knownInteractions.has(pending.id)) continue; - knownInteractions.add(pending.id); - interactionAgents.set(pending.id, interactionAgentId(pending)); - unseeded.set(pending.id, pending); - } - const seedPendingInteractions = (agentId?: string): void => { - if (agentId === undefined) seededAll = true; - else seededAgents.add(agentId); - for (const [id, interaction] of unseeded) { - if (agentId !== undefined && interactionAgents.get(id) !== agentId) continue; - unseeded.delete(id); - announceInteraction(interaction); - const early = earlyResolves.get(id); - if (early === undefined) continue; - interactionAgents.delete(id); - earlyResolves.delete(id); - const projector = projectors.get(early.agentId); - if (projector !== undefined) { - applyOps(early.agentId, projector.mapInteractionResolved(id, early.response)); - } - } - for (const pending of pendingInteractions()) { - if (knownInteractions.has(pending.id)) continue; - if (agentId !== undefined && interactionAgentId(pending) !== agentId) continue; - knownInteractions.add(pending.id); - announceInteraction(pending); - } - }; - disposables.push( - toDisposable( - interactions.onDidChangePending(() => { - for (const pending of pendingInteractions()) { - if (knownInteractions.has(pending.id)) continue; - const agentId = interactionAgentId(pending); - knownInteractions.add(pending.id); - if (!isSeeded(agentId)) { - interactionAgents.set(pending.id, agentId); - unseeded.set(pending.id, pending); - continue; - } - announceInteraction(pending); - } - }), - ), - toDisposable( - interactions.onDidResolve(({ id, response }) => { - knownInteractions.delete(id); - const agentId = interactionAgents.get(id); - if (agentId === undefined) return; - interactionAgents.delete(id); - if (unseeded.has(id)) { - earlyResolves.set(id, { agentId, response }); - return; - } - const projector = projectors.get(agentId); - if (projector === undefined) return; - applyOps(agentId, projector.mapInteractionResolved(id, response)); - }), - ), - ); - - refreshDescriptors(); - - return { - seedPendingInteractions, - dispose: () => { - for (const d of disposables) d.dispose(); - for (const list of agentDisposables.values()) { - for (const d of list) d.dispose(); - } - agentDisposables.clear(); - projectors.clear(); - interactionAgents.clear(); - knownInteractions.clear(); - unseeded.clear(); - earlyResolves.clear(); - }, - }; -} - -export function descriptorFromMeta(agentId: string, meta: AgentMeta | undefined): AgentDescriptor { - const parentFromLabels = meta?.labels?.['parentAgentId']; - const swarmItem = meta?.labels?.['swarmItem'] ?? meta?.swarmItem; - return { - agentId, - type: meta?.type ?? (agentId === MAIN_AGENT_ID ? 'main' : 'sub'), - parentAgentId: - parentFromLabels !== undefined && parentFromLabels.length > 0 - ? parentFromLabels - : (meta?.parentAgentId ?? undefined), - label: swarmItem !== undefined && swarmItem.length > 0 ? swarmItem : undefined, - }; -} diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts deleted file mode 100644 index 5c25d3bf7d6..00000000000 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ /dev/null @@ -1,1701 +0,0 @@ -import type { ContextSpliced } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextEvents'; -import type { HookResult } from '@moonshot-ai/agent-core-v2/features/externalHooks/agent/agentExternalHooksService'; -import type { - CompactionBlocked, - CompactionCancelled, - CompactionCompleted, - CompactionStarted, -} from '@moonshot-ai/agent-core-v2/agent/fullCompaction/compactionOps'; -import { daemonFileRefFromPart, type ContentPart, type ContextUndone, type CronFired, type GoalUpdated } from '@moonshot-ai/agent-core-v2'; -import type { - AssistantDelta, - ThinkingDelta, - ToolCallDelta, - TurnStarted, - TurnStepCompleted, - TurnStepInterrupted, - TurnStepRetrying, - TurnStepStarted, -} from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; -import type { TurnEnded, TurnSteer } from '@moonshot-ai/agent-core-v2/agent/loop/turnOps'; -import type { AgentActivitySnapshot } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; -import type { AgentErrorEvent } from '@moonshot-ai/agent-core-v2/agent/mcp/mcpEvents'; -import type { PluginCommandActivated } from '@moonshot-ai/agent-core-v2/agent/pluginCommand/pluginCommand'; -import type { WarningIssued } from '@moonshot-ai/agent-core-v2/agent/profile/profileOps'; -import type { - PromptAborted, - PromptCompleted, - PromptStarted, - PromptSteered, - PromptSubmitted, -} from '@moonshot-ai/agent-core-v2/agent/prompt/promptService'; -import type { PromptAccepted } from '@moonshot-ai/agent-core-v2/agent/prompt/promptOps'; -import type { PromptQueued } from '@moonshot-ai/agent-core-v2/agent/prompt/promptService'; -import type { - ShellCompleted, - ShellOutput, - ShellStarted, -} from '@moonshot-ai/agent-core-v2/agent/shellCommand/shellCommandService'; -import type { SkillActivated } from '@moonshot-ai/agent-core-v2/features/skill/skillOps'; -import type { - TaskNotified, - TaskStarted, - TaskTerminatedNotice, -} from '@moonshot-ai/agent-core-v2/agent/task/taskOps'; -import type { - PermissionApprovalRequested, - PermissionApprovalResolved, -} from '@moonshot-ai/agent-core-v2/agent/toolApproval/toolApprovalService'; -import type { - ToolCallStarted, - ToolProgress, - ToolResultEvent, -} from '@moonshot-ai/agent-core-v2/agent/toolExecutor/toolExecutorEvents'; -import type { AgentStatusUpdated } from '@moonshot-ai/agent-core-v2/agent/usage/usageEvents'; -import type { PlanRevision } from '@moonshot-ai/agent-core-v2/features/plan/planOps'; -import type { SubagentSuspended } from '@moonshot-ai/agent-core-v2/features/swarm/session/sessionSwarmService'; -import type { - SubagentCompleted, - SubagentFailed, - SubagentSpawned, - SubagentStarted, -} from '@moonshot-ai/agent-core-v2/session/subagent/mirrorAgentRun'; -import { - projectTranscriptUserOrigin, - type AgentRef, - type AgentUsageMeta, - type StepHeader, - type StepUsage, - type TextFrame, - type ToolCallFrame, - type ToolFrameProgress, - type TranscriptAttachment, - type TranscriptFrame, - type TranscriptInteraction, - type TranscriptItem, - type TranscriptMarker, - type TranscriptOperation, - type TranscriptPrompt, - type TranscriptTask, - type TranscriptTodo, - type TranscriptUsage, - type TranscriptUserOrigin, - type TurnHeader, - type TurnOrigin, - type TurnState, -} from '@moonshot-ai/transcript'; - -import { toLegacyPhase, type LegacyActivityApproval } from '../legacyStatus/legacyStatus'; -import { LegacyActivityTracker, phaseFromDomainEvent } from '../legacyStatus/legacyActivity'; -import { toWireQuestion } from '../../protocol/question-wire'; -import { projectPromptContentParts } from '../messages/messageProjection'; - -export interface ProjectorInteraction { - readonly id: string; - readonly kind: 'approval' | 'question'; - readonly payload: unknown; - readonly createdAt: number; -} - -type PlanRevisionEvent = { readonly type: 'plan.revision' } & PlanRevision; - -type PromptAcceptedEvent = { readonly type: 'prompt.accepted' } & PromptAccepted; -type PromptQueuedEvent = { readonly type: 'prompt.queued' } & PromptQueued; -type PromptSubmittedEvent = { readonly type: 'prompt.submitted' } & PromptSubmitted; -type PromptStartedEvent = { readonly type: 'prompt.started' } & PromptStarted; -type PromptCompletedEvent = { readonly type: 'prompt.completed' } & PromptCompleted; -type PromptAbortedEvent = { readonly type: 'prompt.aborted' } & PromptAborted; -type PromptSteeredEvent = { readonly type: 'prompt.steered' } & PromptSteered; -type TurnSteerEvent = { readonly type: 'turn.steer' } & TurnSteer; - -export type ProjectorBusEvent = - | PlanRevisionEvent - | ({ readonly type: 'turn.started' } & TurnStarted) - | ({ readonly type: 'turn.ended' } & TurnEnded) - | ({ readonly type: 'turn.step.started' } & TurnStepStarted) - | ({ readonly type: 'turn.step.completed' } & TurnStepCompleted) - | ({ readonly type: 'turn.step.interrupted' } & TurnStepInterrupted) - | ({ readonly type: 'turn.step.retrying' } & TurnStepRetrying) - | ({ readonly type: 'assistant.delta' } & AssistantDelta) - | ({ readonly type: 'thinking.delta' } & ThinkingDelta) - | ({ readonly type: 'tool.call.delta' } & ToolCallDelta) - | ({ readonly type: 'tool.progress' } & ToolProgress) - | ({ readonly type: 'tool.call.started' } & ToolCallStarted) - | ({ readonly type: 'tool.result' } & ToolResultEvent) - | ({ readonly type: 'permission.approval.requested' } & PermissionApprovalRequested) - | ({ readonly type: 'permission.approval.resolved' } & PermissionApprovalResolved) - | ({ readonly type: 'task.started' } & TaskStarted) - | ({ readonly type: 'task.terminated' } & TaskTerminatedNotice) - | ({ readonly type: 'task.notified' } & TaskNotified) - | ({ readonly type: 'shell.started' } & ShellStarted) - | ({ readonly type: 'shell.output' } & ShellOutput) - | ({ readonly type: 'shell.completed' } & ShellCompleted) - | ({ readonly type: 'subagent.spawned' } & SubagentSpawned) - | ({ readonly type: 'subagent.started' } & SubagentStarted) - | ({ readonly type: 'subagent.completed' } & SubagentCompleted) - | ({ readonly type: 'subagent.failed' } & SubagentFailed) - | ({ readonly type: 'subagent.suspended' } & SubagentSuspended) - | ({ readonly type: 'goal.updated' } & GoalUpdated) - | ({ readonly type: 'agent.status.updated' } & AgentStatusUpdated) - | PromptAcceptedEvent - | PromptQueuedEvent - | PromptSubmittedEvent - | PromptStartedEvent - | PromptCompletedEvent - | PromptAbortedEvent - | PromptSteeredEvent - | TurnSteerEvent - | ({ readonly type: 'hook.result' } & HookResult) - | ({ readonly type: 'skill.activated' } & SkillActivated) - | ({ readonly type: 'plugin_command.activated' } & PluginCommandActivated) - | ({ readonly type: 'cron.fired' } & CronFired) - | ({ readonly type: 'compaction.started' } & CompactionStarted) - | ({ readonly type: 'compaction.blocked' } & CompactionBlocked) - | ({ readonly type: 'compaction.cancelled' } & CompactionCancelled) - | ({ readonly type: 'compaction.completed' } & CompactionCompleted) - | ({ readonly type: 'context.spliced' } & ContextSpliced) - | ({ readonly type: 'context.undone' } & ContextUndone) - | ({ readonly type: 'error' } & AgentErrorEvent) - | ({ readonly type: 'warning' } & WarningIssued); - -export type ProjectorFrameLookup = ( - turnId: string, - stepId: string, -) => readonly TranscriptFrame[] | undefined; - -export type ProjectorToolFrameLookup = (toolCallId: string) => ToolFrameRecord | undefined; - -export type ProjectorStepOrdinalLookup = (turnId: string) => number | undefined; - -export type ProjectorTurnLookup = (turnId: string) => TurnHeader | undefined; - -export type ProjectorItemsLookup = () => readonly TranscriptItem[] | undefined; - -export type ProjectorPlanRevisionKey = (key: string) => string; - -export interface ProjectorLookups { - readonly stepFrames?: ProjectorFrameLookup; - readonly toolFrame?: ProjectorToolFrameLookup; - readonly stepOrdinal?: ProjectorStepOrdinalLookup; - readonly turn?: ProjectorTurnLookup; - readonly items?: ProjectorItemsLookup; - readonly resolvePlanRevisionKey?: ProjectorPlanRevisionKey; - readonly activitySnapshot?: () => AgentActivitySnapshot; - readonly pendingApprovals?: () => readonly LegacyActivityApproval[]; -} - -interface OpenTextFrame { - readonly frameId: string; - offset: number; - text: string; -} - -export interface ToolFrameRecord { - readonly turnId: string; - readonly stepId: string; - readonly frame: ToolCallFrame; -} - -export class AgentTranscriptProjector { - private currentTurn: TurnHeader | undefined; - private currentStep: StepHeader | undefined; - private pendingTaskNotifications: { text: string; taskId: string | undefined }[] = []; - private pendingSteers: { - input: readonly ContentPart[]; - promptIds: readonly string[] | undefined; - origin: TranscriptUserOrigin; - }[] = []; - private unpairedSteerPromptIds: string[][] = []; - private readonly stepOrdinals = new Map(); - private frameOrdinal = 0; - private attachmentOrdinal = 0; - private openText: OpenTextFrame | undefined; - private openThinking: OpenTextFrame | undefined; - private readonly toolFrames = new Map(); - private readonly tasks = new Map(); - private readonly shellTasks = new Map(); - private readonly subagentTaskIds = new Map(); - private activityTracker: LegacyActivityTracker | undefined; - - seedSubagentTask(info: { - readonly taskId: string; - readonly agentId: string; - readonly description: string; - readonly status: string; - readonly detached: boolean; - readonly startedAt: number; - }): TranscriptOperation[] { - if (info.status !== 'running') return []; - this.subagentTaskIds.set(info.agentId, info.taskId); - const task = this.upsertTask(info.taskId, (prev) => ({ - taskId: info.taskId, - kind: 'subagent', - state: 'running', - detached: info.detached, - description: info.description, - agentId: info.agentId, - outputTail: prev?.outputTail ?? '', - startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt), - endedAt: prev?.endedAt, - })); - return [{ op: 'task.upsert', task }]; - } - - seedActiveTurn(info: { turnId: number; promptId?: string }): void { - const turnId = `t${info.turnId}`; - const prev = this.lookups?.turn?.(turnId); - this.currentTurn = { - kind: 'turn', - turnId, - ordinal: info.turnId, - state: 'running', - triggerPromptId: info.promptId ?? prev?.triggerPromptId, - origin: prev?.origin ?? { kind: 'other' }, - prompt: prev?.prompt, - attachmentIds: prev?.attachmentIds, - startedAt: prev?.startedAt, - }; - } - private readonly interactions = new Map(); - private readonly prompts = new Map(); - private readonly stepUsageByTurn = new Map(); - private markerSeq = 0; - private planModeActive = false; - - constructor( - readonly agentId: string, - private readonly sessionId: string, - private readonly lookups?: ProjectorLookups, - ) {} - - map(event: ProjectorBusEvent): TranscriptOperation[] { - const ops = this.mapEvent(event); - const phase = this.phaseFor(event); - if (phase === undefined) return ops; - return [...ops, { op: 'meta.merge', meta: { agent: { phase } } }]; - } - - private phaseFor(event: ProjectorBusEvent): ReturnType { - if (this.lookups?.activitySnapshot === undefined || this.lookups.pendingApprovals === undefined) { - return undefined; - } - this.activityTracker ??= new LegacyActivityTracker( - this.lookups.activitySnapshot, - this.lookups.pendingApprovals, - ); - return phaseFromDomainEvent(this.activityTracker, event); - } - - private mapEvent(event: ProjectorBusEvent): TranscriptOperation[] { - switch (event.type) { - case 'plan.revision': - return this.onPlanRevision(event); - case 'turn.started': - return this.onTurnStarted(event); - case 'turn.ended': - return this.onTurnEnded(event); - case 'turn.step.started': - return this.onStepStarted(event); - case 'turn.step.completed': - return this.onStepCompleted(event); - case 'turn.step.interrupted': - return this.onStepFinished(event); - case 'turn.step.retrying': - return this.onStepRetrying(event); - case 'assistant.delta': - return this.onTextDelta(event.turnId, 'assistant', event.delta); - case 'thinking.delta': - return this.onTextDelta(event.turnId, 'thinking', event.delta); - case 'tool.call.delta': - return this.onToolCallDelta(event); - case 'tool.progress': - return this.onToolProgress(event); - case 'tool.call.started': - return this.onToolCallStarted(event); - case 'tool.result': - return this.onToolResult(event); - case 'permission.approval.requested': - case 'permission.approval.resolved': - return []; - case 'task.started': - case 'task.terminated': - return this.onTaskLifecycle(event); - case 'task.notified': - return this.onTaskNotified(event); - case 'shell.started': - return this.onShellStarted(event); - case 'shell.output': - return this.onShellOutput(event); - case 'shell.completed': - return this.onShellCompleted(event); - case 'subagent.spawned': - return this.onSubagentSpawned(event); - case 'subagent.started': - case 'subagent.completed': - case 'subagent.failed': - case 'subagent.suspended': - return this.onSubagentRun(event); - case 'goal.updated': - return this.onGoalUpdated(event); - case 'agent.status.updated': - return this.onAgentStatusUpdated(event); - case 'prompt.accepted': - return this.onPromptAccepted(event); - case 'prompt.queued': - return this.onPromptQueued(event); - case 'prompt.submitted': - return this.onPromptSubmitted(event); - case 'prompt.started': - return this.onPromptStarted(event); - case 'prompt.completed': - return this.onPromptCompleted(event); - case 'prompt.aborted': - return this.onPromptAborted(event); - case 'prompt.steered': - return this.onPromptSteered(event); - case 'turn.steer': - return this.onTurnSteered(event); - case 'hook.result': - return [this.markerOp('hook', restOf(event))]; - case 'skill.activated': - return [this.markerOp('skill', restOf(event))]; - case 'plugin_command.activated': - return [this.markerOp('skill', { ...restOf(event), variant: 'plugin_command' })]; - case 'cron.fired': - return [this.markerOp('cron.fired', restOf(event))]; - case 'compaction.started': - case 'compaction.blocked': - case 'compaction.cancelled': - case 'compaction.completed': - return [ - this.markerOp('compaction', { - phase: event.type.slice('compaction.'.length), - ...restOf(event), - }), - ]; - case 'context.spliced': - return [this.markerOp('undo', restOf(event))]; - case 'context.undone': - return this.onContextUndone(event); - case 'error': - return [this.noticeOp('error', event.message, restOf(event))]; - case 'warning': - return [this.noticeOp('warning', event.message, restOf(event))]; - default: - return []; - } - } - - private onTurnStarted(event: { - turnId: number; - promptId?: string; - origin: unknown; - prompt?: string; - promptAttachments?: readonly ( - | { kind: 'image' | 'video' | 'audio'; fileId: string; name?: string } - | { kind: 'file'; name: string; mediaType: string; size: number; path: string } - )[]; - }): TranscriptOperation[] { - const n = event.turnId; - const turnId = `t${n}`; - const ops: TranscriptOperation[] = []; - const attachmentIds: string[] = []; - for (const input of event.promptAttachments ?? []) { - const attachment: TranscriptAttachment = - input.kind === 'file' - ? { - attachmentId: `${turnId}.att${attachmentIds.length + 1}`, - mediaType: input.mediaType, - name: input.name, - size: input.size, - } - : { - attachmentId: `${turnId}.att${attachmentIds.length + 1}`, - mediaType: `${input.kind}/*`, - name: input.name, - source: { kind: 'session_media', fileId: input.fileId }, - }; - ops.push({ op: 'attachment.upsert', attachment }); - attachmentIds.push(attachment.attachmentId); - } - this.currentTurn = { - kind: 'turn', - turnId, - triggerPromptId: event.promptId, - ordinal: n, - state: 'running', - origin: mapTurnOrigin(event.origin), - prompt: event.prompt, - attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, - startedAt: nowIso(), - }; - this.currentStep = undefined; - this.pendingTaskNotifications = []; - this.pendingSteers = []; - this.openText = undefined; - this.openThinking = undefined; - ops.push({ op: 'turn.upsert', turn: this.currentTurn }); - ops.push({ op: 'meta.merge', meta: { activity: 'turn' } }); - return ops; - } - - private onTurnEnded(event: { - time?: number; - turnId: number; - reason: 'completed' | 'cancelled' | 'failed' | 'blocked'; - error?: { message: string }; - durationMs?: number; - interruptReason?: string; - }): TranscriptOperation[] { - const ops: TranscriptOperation[] = []; - this.flushOpenFrames(ops); - const turnId = `t${event.turnId}`; - if (this.currentStep !== undefined && this.currentStep.state === 'running') { - const step: StepHeader = { ...this.currentStep, state: 'interrupted', endedAt: nowIso() }; - this.currentStep = step; - ops.push({ op: 'step.upsert', turnId: step.turnId, step }); - } - if (this.currentStep === undefined && this.pendingSteers.length > 0) { - const ordinal = (this.stepOrdinals.get(turnId) ?? this.lookups?.stepOrdinal?.(turnId) ?? 0) + 1; - const step: StepHeader = { - kind: 'step', - stepId: `${turnId}.${ordinal}`, - turnId, - ordinal, - state: 'interrupted', - endedAt: nowIso(), - }; - this.stepOrdinals.set(turnId, ordinal); - this.currentStep = step; - ops.push({ op: 'step.upsert', turnId, step }); - } - if (this.currentStep !== undefined) { - for (const pending of this.pendingSteers) { - this.steerUserFrame( - ops, - turnId, - this.currentStep.stepId, - pending.input, - pending.promptIds, - pending.origin, - ); - } - } - this.pendingSteers = []; - const prev = - this.currentTurn?.turnId === turnId ? this.currentTurn : this.lookups?.turn?.(turnId); - const state = mapTurnEndState(event.reason); - this.currentTurn = { - kind: 'turn', - turnId, - ordinal: event.turnId, - state, - triggerPromptId: prev?.triggerPromptId, - origin: prev?.origin ?? { kind: 'other' }, - prompt: prev?.prompt, - attachmentIds: prev?.attachmentIds, - startedAt: prev?.startedAt, - endedAt: event.time === undefined ? nowIso() : epochMsToIso(event.time), - durationMs: event.durationMs, - error: event.error?.message, - usage: this.takeTurnUsage(turnId), - }; - ops.push({ op: 'turn.upsert', turn: this.currentTurn }); - ops.push({ op: 'meta.merge', meta: { activity: 'idle' } }); - this.currentStep = undefined; - this.pendingTaskNotifications = []; - if (event.reason === 'cancelled' && event.interruptReason === 'user_cancelled') { - ops.push( - this.markerOp('interruption', { turnId: event.turnId, reason: event.interruptReason }), - ); - } - return ops; - } - - private takeTurnUsage(turnId: string): TranscriptUsage | undefined { - const usages = this.stepUsageByTurn.get(turnId); - this.stepUsageByTurn.delete(turnId); - if (usages === undefined || usages.length === 0) return undefined; - let inputOther = 0; - let output = 0; - let inputCacheRead = 0; - let inputCacheCreation = 0; - for (const usage of usages) { - inputOther += usage.inputOther; - output += usage.output; - inputCacheRead += usage.inputCacheRead; - inputCacheCreation += usage.inputCacheCreation; - } - return { - inputTokens: inputOther + inputCacheCreation, - cachedTokens: inputCacheRead, - outputTokens: output, - }; - } - - private onStepStarted(event: { turnId: number; step: number }): TranscriptOperation[] { - const turnId = `t${event.turnId}`; - const stepId = `${turnId}.${event.step}`; - this.stepOrdinals.set(turnId, event.step); - this.currentStep = { - kind: 'step', - stepId, - turnId, - ordinal: event.step, - state: 'running', - startedAt: nowIso(), - }; - this.frameOrdinal = 0; - this.attachmentOrdinal = 0; - this.openText = undefined; - this.openThinking = undefined; - const ops: TranscriptOperation[] = [{ op: 'step.upsert', turnId, step: this.currentStep }]; - for (const pending of this.pendingTaskNotifications) { - ops.push({ - op: 'frame.upsert', - turnId, - stepId, - frame: { - kind: 'text', - frameId: `${stepId}.f${++this.frameOrdinal}`, - role: 'user', - text: pending.text, - taskId: pending.taskId, - }, - }); - } - this.pendingTaskNotifications = []; - for (const pending of this.pendingSteers) { - this.steerUserFrame(ops, turnId, stepId, pending.input, pending.promptIds, pending.origin); - } - this.pendingSteers = []; - return ops; - } - - private onStepCompleted(event: { - turnId: number; - step: number; - usage?: StepUsage; - finishReason?: string; - rawFinishReason?: string; - providerFinishReason?: string; - llmFirstTokenLatencyMs?: number; - llmStreamDurationMs?: number; - llmRequestBuildMs?: number; - llmServerFirstTokenMs?: number; - llmServerDecodeMs?: number; - llmClientConsumeMs?: number; - llmClientBlockedMs?: number; - }): TranscriptOperation[] { - const ops: TranscriptOperation[] = []; - this.flushOpenFrames(ops); - const turnId = `t${event.turnId}`; - const stepId = `${turnId}.${event.step}`; - const prev = this.currentStep?.stepId === stepId ? this.currentStep : undefined; - if (event.usage !== undefined) { - const usages = this.stepUsageByTurn.get(turnId) ?? []; - usages.push(event.usage); - this.stepUsageByTurn.set(turnId, usages); - } - this.currentStep = { - kind: 'step', - stepId, - turnId, - ordinal: event.step, - state: 'completed', - startedAt: prev?.startedAt, - endedAt: nowIso(), - usage: event.usage, - finishReason: event.finishReason ?? event.rawFinishReason ?? event.providerFinishReason, - timing: { - llmFirstTokenLatencyMs: event.llmFirstTokenLatencyMs, - llmStreamDurationMs: event.llmStreamDurationMs, - llmRequestBuildMs: event.llmRequestBuildMs, - llmServerFirstTokenMs: event.llmServerFirstTokenMs, - llmServerDecodeMs: event.llmServerDecodeMs, - llmClientConsumeMs: event.llmClientConsumeMs, - llmClientBlockedMs: event.llmClientBlockedMs, - }, - }; - ops.push({ op: 'step.upsert', turnId, step: this.currentStep }); - return ops; - } - - private onStepFinished(event: { - type: 'turn.step.interrupted'; - turnId: number; - step: number; - reason: string; - message?: string; - }): TranscriptOperation[] { - const ops: TranscriptOperation[] = []; - this.flushOpenFrames(ops); - const turnId = `t${event.turnId}`; - const stepId = `${turnId}.${event.step}`; - const prev = this.currentStep?.stepId === stepId ? this.currentStep : undefined; - this.currentStep = { - kind: 'step', - stepId, - turnId, - ordinal: event.step, - state: 'interrupted', - startedAt: prev?.startedAt, - endedAt: nowIso(), - endReason: event.reason, - endMessage: event.message, - }; - ops.push({ op: 'step.upsert', turnId, step: this.currentStep }); - return ops; - } - - private onStepRetrying(event: { - turnId: number; - step: number; - failedAttempt: number; - nextAttempt: number; - maxAttempts: number; - delayMs: number; - errorName: string; - errorMessage: string; - statusCode?: number; - }): TranscriptOperation[] { - const ops: TranscriptOperation[] = []; - const turnId = `t${event.turnId}`; - const stepId = `${turnId}.${event.step}`; - const prev = this.currentStep?.stepId === stepId ? this.currentStep : undefined; - this.currentStep = { - kind: 'step', - stepId, - turnId, - ordinal: event.step, - state: 'running', - startedAt: prev?.startedAt, - retry: { - failedAttempt: event.failedAttempt, - nextAttempt: event.nextAttempt, - maxAttempts: event.maxAttempts, - delayMs: event.delayMs, - errorName: event.errorName, - errorMessage: event.errorMessage, - statusCode: event.statusCode, - }, - }; - ops.push({ op: 'step.upsert', turnId, step: this.currentStep }); - return ops; - } - - private onTextDelta( - turnNumber: number, - kind: 'assistant' | 'thinking', - delta: string, - ): TranscriptOperation[] { - const ops: TranscriptOperation[] = []; - const turnId = `t${turnNumber}`; - const step = this.ensureStep(turnId, ops); - let open = kind === 'assistant' ? this.openText : this.openThinking; - open ??= this.adoptStreamFrame(turnId, step.stepId, kind); - if (open === undefined) { - const frameId = `${step.stepId}.f${++this.frameOrdinal}`; - open = { frameId, offset: 0, text: '' }; - ops.push({ - op: 'frame.upsert', - turnId, - stepId: step.stepId, - frame: - kind === 'assistant' - ? { kind: 'text', frameId, role: 'assistant', text: '' } - : { kind: 'thinking', frameId, text: '' }, - }); - } - ops.push({ - op: 'append', - target: { type: 'frame', turnId, stepId: step.stepId, frameId: open.frameId }, - offset: open.offset, - text: delta, - }); - open.offset += delta.length; - open.text += delta; - if (kind === 'assistant') this.openText = open; - else this.openThinking = open; - return ops; - } - - private adoptStreamFrame( - turnId: string, - stepId: string, - kind: 'assistant' | 'thinking', - ): OpenTextFrame | undefined { - const frames = this.lookups?.stepFrames?.(turnId, stepId); - if (frames === undefined || frames.length === 0) return undefined; - for (const frame of frames) { - const match = /\.f(\d+)$/.exec(frame.frameId); - if (match !== null) { - this.frameOrdinal = Math.max(this.frameOrdinal, Number(match[1])); - } - } - for (let i = frames.length - 1; i >= 0; i -= 1) { - const frame = frames[i]; - if (frame === undefined) continue; - if (kind === 'assistant' && frame.kind === 'text' && frame.role === 'assistant') { - return { frameId: frame.frameId, offset: frame.text.length, text: frame.text }; - } - if (kind === 'thinking' && frame.kind === 'thinking') { - return { frameId: frame.frameId, offset: frame.text.length, text: frame.text }; - } - } - return undefined; - } - - private flushOpenFrames(ops: TranscriptOperation[]): void { - const step = this.currentStep; - for (const open of [this.openText, this.openThinking]) { - if (open === undefined || step === undefined) continue; - const isText = open === this.openText; - ops.push({ - op: 'frame.upsert', - turnId: step.turnId, - stepId: step.stepId, - frame: isText - ? { kind: 'text', frameId: open.frameId, role: 'assistant', text: open.text } - : { kind: 'thinking', frameId: open.frameId, text: open.text }, - }); - } - this.openText = undefined; - this.openThinking = undefined; - } - - private ensureStep(turnId: string, ops: TranscriptOperation[]): StepHeader { - if (this.currentStep !== undefined && this.currentStep.turnId === turnId) { - return this.currentStep; - } - const ordinal = - this.lookups?.stepOrdinal?.(turnId) ?? this.stepOrdinals.get(turnId) ?? 1; - this.currentStep = { - kind: 'step', - stepId: `${turnId}.${ordinal}`, - turnId, - ordinal, - state: 'running', - startedAt: nowIso(), - }; - ops.push({ op: 'step.upsert', turnId, step: this.currentStep }); - return this.currentStep; - } - - private onToolCallDelta(event: { - turnId: number; - toolCallId: string; - name?: string; - argumentsPart?: string; - }): TranscriptOperation[] { - const ops: TranscriptOperation[] = []; - const prev = this.toolFrames.get(event.toolCallId); - if (prev !== undefined) { - const frame: ToolCallFrame = { - ...prev.frame, - inputText: (prev.frame.inputText ?? '') + (event.argumentsPart ?? ''), - }; - this.toolFrames.set(event.toolCallId, { ...prev, frame }); - ops.push({ op: 'frame.upsert', turnId: prev.turnId, stepId: prev.stepId, frame }); - return ops; - } - const turnId = `t${event.turnId}`; - const step = this.ensureStep(turnId, ops); - const frameId = `${step.stepId}.${event.toolCallId}`; - const frame: ToolCallFrame = { - kind: 'tool', - frameId, - toolCallId: event.toolCallId, - name: event.name ?? '', - state: 'running', - inputText: event.argumentsPart ?? '', - }; - this.toolFrames.set(event.toolCallId, { turnId, stepId: step.stepId, frame }); - ops.push({ op: 'frame.upsert', turnId, stepId: step.stepId, frame }); - return ops; - } - - private onToolProgress(event: { - toolCallId: string; - update: ToolFrameProgress; - }): TranscriptOperation[] { - const hit = this.toolFrames.get(event.toolCallId) ?? this.adoptToolFrame(event.toolCallId); - if (hit === undefined) return []; - const frame: ToolCallFrame = { - ...hit.frame, - progress: { - kind: event.update.kind, - text: event.update.text, - percent: event.update.percent, - customKind: event.update.customKind, - customData: event.update.customData, - }, - }; - this.toolFrames.set(event.toolCallId, { ...hit, frame }); - return [{ op: 'frame.upsert', turnId: hit.turnId, stepId: hit.stepId, frame }]; - } - - private onToolCallStarted(event: { - turnId: number; - toolCallId: string; - name: string; - args: unknown; - display?: unknown; - }): TranscriptOperation[] { - const ops: TranscriptOperation[] = []; - const turnId = `t${event.turnId}`; - const step = this.ensureStep(turnId, ops); - const frameId = `${step.stepId}.${event.toolCallId}`; - const input = parseToolArgs(event.args); - const frame: ToolCallFrame = { - kind: 'tool', - frameId, - toolCallId: event.toolCallId, - name: event.name, - state: 'running', - input, - inputText: this.toolFrames.get(event.toolCallId)?.frame.inputText, - display: event.display, - todoId: event.name === TODO_LIST_TOOL_NAME && todoWriteItems(input) !== undefined ? TODO_ENTITY_ID : undefined, - }; - this.toolFrames.set(event.toolCallId, { turnId, stepId: step.stepId, frame }); - ops.push({ op: 'frame.upsert', turnId, stepId: step.stepId, frame }); - return ops; - } - - private onToolResult(event: { - toolCallId: string; - output: unknown; - isError?: boolean; - }): TranscriptOperation[] { - const hit = this.toolFrames.get(event.toolCallId) ?? this.adoptToolFrame(event.toolCallId); - if (hit === undefined) return []; - const isError = event.isError === true; - const frame: ToolCallFrame = { - ...hit.frame, - state: isError ? 'error' : 'done', - output: event.output, - error: isError && typeof event.output === 'string' ? event.output : undefined, - }; - this.toolFrames.set(event.toolCallId, { ...hit, frame }); - const ops: TranscriptOperation[] = [ - { op: 'frame.upsert', turnId: hit.turnId, stepId: hit.stepId, frame }, - ]; - if (!isError && frame.name === TODO_LIST_TOOL_NAME) { - const items = todoWriteItems(frame.input); - if (items !== undefined) { - const todo: TranscriptTodo = { todoId: TODO_ENTITY_ID, items, updatedAt: nowIso() }; - ops.push({ op: 'todo.upsert', todo }); - } - } - return ops; - } - - private adoptToolFrame(toolCallId: string): ToolFrameRecord | undefined { - const hit = this.lookups?.toolFrame?.(toolCallId); - if (hit === undefined) return undefined; - this.toolFrames.set(toolCallId, hit); - return hit; - } - - private onTaskNotified(event: { - notificationType: string; - title: string; - body: string; - severity: string; - sourceKind: string; - sourceId: string; - }): TranscriptOperation[] { - const step = this.currentStep; - const turn = this.currentTurn; - if (turn === undefined || turn.state !== 'running') return []; - const text = `${event.title}\n${event.body}`.trim(); - if (step !== undefined && step.state === 'running') { - const frame: TextFrame = { - kind: 'text', - frameId: `${step.stepId}.f${++this.frameOrdinal}`, - role: 'user', - text, - taskId: event.sourceId, - }; - return [{ op: 'frame.upsert', turnId: turn.turnId, stepId: step.stepId, frame }]; - } - if (turn.origin?.kind === 'task' && (turn.origin.taskId === undefined || turn.origin.taskId === event.sourceId)) return []; - this.pendingTaskNotifications.push({ text, taskId: event.sourceId }); - return []; - } - - private onTaskLifecycle(event: { - type: 'task.started' | 'task.terminated'; - info: { - taskId: string; - kind: string; - description: string; - status: TranscriptTask['state']; - detached?: boolean; - agentId?: string; - startedAt: number; - endedAt: number | null; - }; - }): TranscriptOperation[] { - const { info } = event; - const task = this.upsertTask(info.taskId, (prev) => ({ - taskId: info.taskId, - kind: mapTaskKind(info.kind), - state: info.status, - detached: info.detached ?? prev?.detached ?? true, - description: info.description, - agentId: info.agentId ?? prev?.agentId, - outputTail: prev?.outputTail ?? '', - startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt), - endedAt: info.endedAt === null ? prev?.endedAt : epochMsToIso(info.endedAt), - resultSummary: prev?.resultSummary, - usage: prev?.usage, - error: prev?.error, - stateReason: prev?.stateReason, - model: prev?.model, - thinkingEffort: prev?.thinkingEffort, - })); - const ops: TranscriptOperation[] = [{ op: 'task.upsert', task }]; - if (event.type === 'task.started') { - if (info.kind === 'agent' && typeof info.agentId === 'string' && info.agentId.length > 0) { - this.subagentTaskIds.set(info.agentId, info.taskId); - } - ops.push({ - op: 'taskref.upsert', - item: { kind: 'taskref', refId: `ref-${info.taskId}`, taskId: info.taskId, at: nowIso() }, - }); - } - return ops; - } - - private onShellStarted(event: { commandId: string; taskId: string }): TranscriptOperation[] { - this.shellTasks.set(event.commandId, event.taskId); - const task = this.upsertTask(event.taskId, (prev) => ({ - taskId: event.taskId, - kind: 'shell', - state: 'running', - detached: prev?.detached ?? false, - description: prev?.description, - agentId: prev?.agentId, - outputTail: prev?.outputTail ?? '', - startedAt: prev?.startedAt ?? nowIso(), - endedAt: prev?.endedAt, - })); - return [ - { op: 'task.upsert', task }, - { - op: 'taskref.upsert', - item: { kind: 'taskref', refId: `ref-${event.taskId}`, taskId: event.taskId, at: nowIso() }, - }, - ]; - } - - private shellTaskId(event: { commandId: string; taskId?: string }): string { - const taskId = this.shellTasks.get(event.commandId) ?? event.taskId ?? `shell-${event.commandId}`; - this.shellTasks.set(event.commandId, taskId); - return taskId; - } - - private onShellOutput(event: { - commandId: string; - taskId?: string; - update: { kind: string; text?: string }; - }): TranscriptOperation[] { - const taskId = this.shellTaskId(event); - const text = event.update.text; - if (typeof text !== 'string' || text.length === 0) return []; - const ops: TranscriptOperation[] = []; - let task = this.tasks.get(taskId); - if (task === undefined) { - task = this.upsertTask(taskId, (prev) => ({ - taskId, - kind: 'shell', - state: 'running', - detached: prev?.detached ?? false, - description: prev?.description, - agentId: prev?.agentId, - outputTail: prev?.outputTail ?? '', - startedAt: prev?.startedAt ?? nowIso(), - endedAt: prev?.endedAt, - })); - ops.push( - { op: 'task.upsert', task }, - { - op: 'taskref.upsert', - item: { kind: 'taskref', refId: `ref-${taskId}`, taskId, at: nowIso() }, - }, - ); - } - const offset = task.outputTail.length; - this.tasks.set(taskId, { ...task, outputTail: task.outputTail + text }); - ops.push({ op: 'append', target: { type: 'task', taskId }, offset, text }); - return ops; - } - - private onShellCompleted(event: { - commandId: string; - taskId?: string; - isError: boolean; - }): TranscriptOperation[] { - const taskId = this.shellTaskId(event); - const hadTask = this.tasks.has(taskId); - const task = this.upsertTask(taskId, (prev) => ({ - taskId, - kind: prev?.kind ?? 'shell', - state: event.isError ? 'failed' : 'completed', - detached: prev?.detached ?? false, - description: prev?.description, - agentId: prev?.agentId, - outputTail: prev?.outputTail ?? '', - startedAt: prev?.startedAt ?? nowIso(), - endedAt: nowIso(), - })); - const ops: TranscriptOperation[] = [{ op: 'task.upsert', task }]; - if (!hadTask) { - ops.push({ - op: 'taskref.upsert', - item: { kind: 'taskref', refId: `ref-${taskId}`, taskId, at: nowIso() }, - }); - } - return ops; - } - - private upsertTask( - taskId: string, - build: (prev: TranscriptTask | undefined) => TranscriptTask, - ): TranscriptTask { - const task = build(this.tasks.get(taskId)); - this.tasks.set(taskId, task); - return task; - } - - private onSubagentSpawned(event: { - subagentId: string; - subagentName: string; - parentToolCallId: string; - description?: string; - swarmIndex?: number; - runInBackground: boolean; - taskId?: string; - model?: string; - thinkingEffort?: string; - }): TranscriptOperation[] { - const taskKey = event.taskId ?? event.subagentId; - if (event.taskId !== undefined) { - this.subagentTaskIds.set(event.subagentId, event.taskId); - } else { - this.subagentTaskIds.delete(event.subagentId); - } - const task = this.upsertTask(taskKey, (prev) => ({ - taskId: taskKey, - kind: 'subagent', - state: 'running', - detached: event.runInBackground, - description: event.description ?? prev?.description, - agentId: event.subagentId, - outputTail: prev?.outputTail ?? '', - startedAt: prev?.startedAt ?? nowIso(), - endedAt: prev?.endedAt, - model: event.model ?? prev?.model, - thinkingEffort: event.thinkingEffort ?? prev?.thinkingEffort, - })); - const ops: TranscriptOperation[] = [{ op: 'task.upsert', task }]; - const hit = - this.toolFrames.get(event.parentToolCallId) ?? this.adoptToolFrame(event.parentToolCallId); - if (hit !== undefined) { - const ref: AgentRef = { - agentId: event.subagentId, - role: event.swarmIndex !== undefined ? 'member' : 'child', - }; - const frame: ToolCallFrame = { - ...hit.frame, - agentRefs: [...(hit.frame.agentRefs ?? []), ref], - }; - this.toolFrames.set(event.parentToolCallId, { ...hit, frame }); - ops.push({ op: 'frame.upsert', turnId: hit.turnId, stepId: hit.stepId, frame }); - } - return ops; - } - - private onSubagentRun(event: { - type: 'subagent.started' | 'subagent.completed' | 'subagent.failed' | 'subagent.suspended'; - subagentId: string; - resultSummary?: string; - usage?: StepUsage; - error?: string; - reason?: string; - }): TranscriptOperation[] { - const state: TranscriptTask['state'] = - event.type === 'subagent.completed' - ? 'completed' - : event.type === 'subagent.failed' - ? 'failed' - : 'running'; - const taskKey = this.subagentTaskIds.get(event.subagentId) ?? event.subagentId; - const task = this.upsertTask(taskKey, (prev) => ({ - taskId: taskKey, - kind: 'subagent', - state, - detached: prev?.detached ?? true, - description: prev?.description, - agentId: event.subagentId, - outputTail: prev?.outputTail ?? '', - startedAt: prev?.startedAt ?? nowIso(), - endedAt: - event.type === 'subagent.completed' || event.type === 'subagent.failed' - ? nowIso() - : prev?.endedAt, - resultSummary: event.resultSummary ?? prev?.resultSummary, - usage: event.usage ?? prev?.usage, - error: event.error ?? prev?.error, - stateReason: event.reason ?? prev?.stateReason, - model: prev?.model, - thinkingEffort: prev?.thinkingEffort, - })); - const ops: TranscriptOperation[] = [{ op: 'task.upsert', task }]; - if (taskKey !== event.subagentId && this.tasks.has(event.subagentId)) { - const agentTask = this.upsertTask(event.subagentId, (prev) => ({ - taskId: event.subagentId, - kind: 'subagent', - state, - detached: prev?.detached ?? true, - description: prev?.description, - agentId: event.subagentId, - outputTail: prev?.outputTail ?? '', - startedAt: prev?.startedAt ?? nowIso(), - endedAt: - event.type === 'subagent.completed' || event.type === 'subagent.failed' - ? nowIso() - : prev?.endedAt, - resultSummary: event.resultSummary ?? prev?.resultSummary, - usage: event.usage ?? prev?.usage, - error: event.error ?? prev?.error, - stateReason: event.reason ?? prev?.stateReason, - model: prev?.model, - thinkingEffort: prev?.thinkingEffort, - })); - ops.push({ op: 'task.upsert', task: agentTask }); - } - return ops; - } - - private onGoalUpdated(event: { - readonly type: string; - snapshot: { - objective: string; - status: 'active' | 'paused' | 'blocked' | 'complete'; - completionCriterion?: string; - tokensUsed: number; - budget: { tokenBudget: number | null }; - } | null; - }): TranscriptOperation[] { - const ops: TranscriptOperation[] = []; - const snapshot = event.snapshot; - if (snapshot === null) { - ops.push({ op: 'meta.merge', meta: { goal: null } }); - } else { - ops.push({ - op: 'meta.merge', - meta: { - goal: { - objective: snapshot.objective, - status: snapshot.status, - completionCriterion: snapshot.completionCriterion, - budgetUsed: snapshot.tokensUsed, - budgetLimit: snapshot.budget.tokenBudget ?? undefined, - }, - }, - }); - } - ops.push(this.markerOp('goal', restOf(event))); - return ops; - } - - private onAgentStatusUpdated(event: { - planMode?: boolean; - swarmMode?: boolean; - towerMode?: boolean; - model?: string; - thinkingEffort?: string; - usage?: AgentUsageMeta; - contextTokens?: number; - maxContextTokens?: number; - contextUsage?: number; - permission?: 'manual' | 'yolo' | 'auto'; - }): TranscriptOperation[] { - const ops: TranscriptOperation[] = []; - const modes: { - plan?: Record | null; - swarm?: Record | null; - tower?: Record | null; - } = {}; - if (event.planMode === true) { - modes.plan = {}; - this.planModeActive = true; - } else if (event.planMode === false) { - modes.plan = null; - this.planModeActive = false; - } - if (event.swarmMode === true) modes.swarm = {}; - else if (event.swarmMode === false) modes.swarm = null; - if (event.towerMode === true) modes.tower = {}; - else if (event.towerMode === false) modes.tower = null; - if (modes.plan !== undefined || modes.swarm !== undefined || modes.tower !== undefined) { - ops.push({ op: 'meta.merge', meta: { modes } }); - } - const agent: { - model?: string; - thinkingEffort?: string; - usage?: AgentUsageMeta; - contextTokens?: number; - maxContextTokens?: number; - contextUsage?: number; - permission?: 'manual' | 'yolo' | 'auto'; - } = {}; - let hasStatusSlice = false; - if (event.model !== undefined) { - agent.model = event.model; - hasStatusSlice = true; - } - if (event.thinkingEffort !== undefined) { - agent.thinkingEffort = event.thinkingEffort; - hasStatusSlice = true; - } - if (event.usage !== undefined) { - agent.usage = event.usage; - hasStatusSlice = true; - } - if (event.contextTokens !== undefined) { - agent.contextTokens = event.contextTokens; - hasStatusSlice = true; - } - if (event.maxContextTokens !== undefined) { - agent.maxContextTokens = event.maxContextTokens; - hasStatusSlice = true; - } - if (event.contextUsage !== undefined) { - agent.contextUsage = event.contextUsage; - hasStatusSlice = true; - } - if (event.permission !== undefined) { - agent.permission = event.permission; - hasStatusSlice = true; - } - if (hasStatusSlice) { - ops.push({ op: 'meta.merge', meta: { agent } }); - } - return ops; - } - - private onPlanRevision(event: PlanRevisionEvent): TranscriptOperation[] { - const path = this.lookups?.resolvePlanRevisionKey?.(event.key) ?? event.key; - const { key: _key, ...rest } = restOf(event); - const payload = { ...rest, path }; - const ops: TranscriptOperation[] = [this.markerOp('plan.revision', payload)]; - if (this.planModeActive) { - ops.push({ - op: 'meta.merge', - meta: { modes: { plan: { reviewPath: path, version: event.version } } }, - }); - } - return ops; - } - - private onContextUndone(event: { turns: number; fromTurnId?: number }): TranscriptOperation[] { - const items = this.lookups?.items?.(); - if (items === undefined) return []; - const ids: string[] = []; - let cutIndex = items.length; - if (event.fromTurnId !== undefined) { - const fromTurnId = event.fromTurnId; - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - if (item === undefined || item.kind !== 'turn') continue; - if (item.ordinal < fromTurnId) break; - ids.push(item.turnId); - cutIndex = i; - } - } else { - let remaining = event.turns; - for (let i = items.length - 1; i >= 0 && remaining > 0; i--) { - const item = items[i]; - if (item === undefined || item.kind !== 'turn') continue; - ids.push(item.turnId); - cutIndex = i; - remaining -= 1; - } - } - if (ids.length === 0) return []; - for (let i = cutIndex + 1; i < items.length; i++) { - const item = items[i]; - if (item?.kind === 'marker' && item.marker === 'undo') ids.push(item.markerId); - } - return [{ op: 'items.remove', ids }]; - } - - private markerOp(marker: string, payload: unknown): TranscriptOperation { - this.markerSeq += 1; - const item: TranscriptMarker = { - kind: 'marker', - markerId: `live-m${this.markerSeq}`, - marker, - payload, - at: nowIso(), - }; - return { op: 'marker.upsert', item }; - } - - private noticeOp( - level: 'error' | 'warning' | 'info', - message: string, - eventPayload: unknown, - ): TranscriptOperation { - return this.markerOp('notice', { level, message, event: eventPayload }); - } - - private onPromptAccepted(event: PromptAcceptedEvent): TranscriptOperation[] { - const prompt = this.upsertPrompt(event.promptId, () => ({ - promptId: event.promptId, - status: 'running', - userMessageId: event.promptId, - content: - event.content === undefined - ? undefined - : projectPromptContentParts(event.content as readonly ContentPart[]), - createdAt: nowIso(), - })); - return [{ op: 'prompt.upsert', prompt }]; - } - - private onPromptQueued(event: PromptQueuedEvent): TranscriptOperation[] { - const prompt = this.upsertPrompt(event.promptId, (prev) => ({ - promptId: event.promptId, - status: 'queued', - userMessageId: prev?.userMessageId, - content: projectPromptContentParts(event.content), - createdAt: prev?.createdAt ?? nowIso(), - })); - return [{ op: 'prompt.upsert', prompt }]; - } - - private onPromptSubmitted(event: PromptSubmittedEvent): TranscriptOperation[] { - const prompt = this.upsertPrompt(event.promptId, (prev) => ({ - promptId: event.promptId, - status: prev !== undefined && isTerminalPromptStatus(prev.status) ? prev.status : event.status, - userMessageId: event.userMessageId, - content: projectPromptContentParts(event.content), - createdAt: prev?.createdAt ?? event.createdAt, - finishedAt: prev?.finishedAt, - steeredAt: prev?.steeredAt, - })); - return [{ op: 'prompt.upsert', prompt }]; - } - - private onPromptStarted(event: PromptStartedEvent): TranscriptOperation[] { - const prompt = this.upsertPrompt(event.promptId, (prev) => ({ - promptId: event.promptId, - status: 'running', - userMessageId: prev?.userMessageId, - content: prev?.content, - createdAt: prev?.createdAt ?? new Date().toISOString(), - finishedAt: prev?.finishedAt, - steeredAt: prev?.steeredAt, - })); - return [{ op: 'prompt.upsert', prompt }]; - } - - private onPromptCompleted(event: PromptCompletedEvent): TranscriptOperation[] { - const prompt = this.upsertPrompt(event.promptId, (prev) => ({ - promptId: event.promptId, - status: event.reason ?? 'completed', - userMessageId: prev?.userMessageId, - content: prev?.content, - createdAt: prev?.createdAt ?? event.finishedAt, - finishedAt: event.finishedAt, - steeredAt: prev?.steeredAt, - })); - return [{ op: 'prompt.upsert', prompt }]; - } - - private onPromptAborted(event: PromptAbortedEvent): TranscriptOperation[] { - const prompt = this.upsertPrompt(event.promptId, (prev) => ({ - promptId: event.promptId, - status: 'aborted', - userMessageId: prev?.userMessageId, - content: prev?.content, - createdAt: prev?.createdAt ?? event.abortedAt, - finishedAt: event.abortedAt, - steeredAt: prev?.steeredAt, - })); - return [{ op: 'prompt.upsert', prompt }]; - } - - private onPromptSteered(event: PromptSteeredEvent): TranscriptOperation[] { - const ops: TranscriptOperation[] = []; - const active = this.upsertPrompt(event.activePromptId, (prev) => ({ - promptId: event.activePromptId, - status: prev?.status ?? 'running', - userMessageId: prev?.userMessageId, - content: projectPromptContentParts(event.content), - createdAt: prev?.createdAt ?? event.steeredAt, - finishedAt: prev?.finishedAt, - steeredAt: event.steeredAt, - })); - ops.push({ op: 'prompt.upsert', prompt: active }); - this.unpairedSteerPromptIds.push([...event.promptIds]); - for (const promptId of event.promptIds) { - const steered = this.upsertPrompt(promptId, (prev) => ({ - promptId, - status: 'completed', - userMessageId: prev?.userMessageId, - content: prev?.content, - createdAt: prev?.createdAt ?? event.steeredAt, - finishedAt: event.steeredAt, - steeredAt: event.steeredAt, - })); - ops.push({ op: 'prompt.upsert', prompt: steered }); - } - return ops; - } - - private onTurnSteered(event: TurnSteerEvent): TranscriptOperation[] { - const origin = event.origin; - if (origin.kind !== 'user') return []; - const frameOrigin = projectTranscriptUserOrigin(origin); - if (frameOrigin === undefined) return []; - const turn = this.currentTurn; - if (turn !== undefined && turn.state !== 'running') return []; - const skip = origin.skillActivations?.length ?? 0; - const input = skip > 0 ? event.input.slice(skip) : event.input; - const step = this.currentStep; - if (step !== undefined && step.state === 'running') { - const ops: TranscriptOperation[] = []; - this.steerUserFrame( - ops, - step.turnId, - step.stepId, - input, - this.unpairedSteerPromptIds.shift(), - frameOrigin, - ); - return ops; - } - this.pendingSteers.push({ - input, - promptIds: this.unpairedSteerPromptIds.shift(), - origin: frameOrigin, - }); - return []; - } - - private steerUserFrame( - ops: TranscriptOperation[], - turnId: string, - stepId: string, - input: readonly ContentPart[], - promptIds: readonly string[] | undefined, - origin: TranscriptUserOrigin, - ): void { - const texts: string[] = []; - const attachmentIds: string[] = []; - for (const part of input) { - if (part.type === 'text') { - texts.push(part.text); - continue; - } - const ref = daemonFileRefFromPart(part); - if (ref === undefined) continue; - const attachment: TranscriptAttachment = { - attachmentId: `${stepId}.att${++this.attachmentOrdinal}`, - mediaType: `${ref.kind}/*`, - name: - part.type === 'image_url' - ? part.imageUrl.name - : part.type === 'video_url' - ? part.videoUrl.name - : undefined, - source: { kind: 'session_media', fileId: ref.ref.fileId }, - }; - ops.push({ op: 'attachment.upsert', attachment }); - attachmentIds.push(attachment.attachmentId); - } - ops.push({ - op: 'frame.upsert', - turnId, - stepId, - frame: { - kind: 'text', - frameId: `${stepId}.f${++this.frameOrdinal}`, - role: 'user', - text: texts.join(''), - attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined, - promptIds, - origin, - }, - }); - } - - private upsertPrompt( - promptId: string, - build: (prev: TranscriptPrompt | undefined) => TranscriptPrompt, - ): TranscriptPrompt { - const prompt = build(this.prompts.get(promptId)); - this.prompts.set(promptId, prompt); - return prompt; - } - - mapInteractionRequested(interaction: ProjectorInteraction): TranscriptOperation[] { - const payload = interaction.payload as { toolCallId?: unknown }; - const toolCallId = typeof payload.toolCallId === 'string' ? payload.toolCallId : undefined; - const entity: TranscriptInteraction = { - interactionId: interaction.id, - interactionKind: interaction.kind, - toolCallId, - state: 'pending', - request: this.wireInteractionRequest(interaction), - }; - this.interactions.set(interaction.id, entity); - return [{ op: 'interaction.upsert', interaction: entity }]; - } - - private wireInteractionRequest(interaction: ProjectorInteraction): unknown { - if (interaction.kind !== 'question') return interaction.payload; - try { - return toWireQuestion(interaction, this.sessionId); - } catch { - return interaction.payload; - } - } - - mapInteractionResolved(id: string, response: unknown): TranscriptOperation[] { - const record = this.interactions.get(id); - if (record === undefined) return []; - this.interactions.delete(id); - const state = mapInteractionEndState(record.interactionKind, response); - const ops: TranscriptOperation[] = [ - { op: 'interaction.upsert', interaction: { ...record, state, response } }, - ]; - const toolCallId = record.toolCallId; - if (toolCallId !== undefined) { - const hit = this.toolFrames.get(toolCallId) ?? this.adoptToolFrame(toolCallId); - if (hit !== undefined) { - const toolFrame: ToolCallFrame = { ...hit.frame, approvalId: id }; - this.toolFrames.set(toolCallId, { ...hit, frame: toolFrame }); - ops.push({ op: 'frame.upsert', turnId: hit.turnId, stepId: hit.stepId, frame: toolFrame }); - } - } - return ops; - } -} - -function nowIso(): string { - return new Date().toISOString(); -} - -function isTerminalPromptStatus(status: TranscriptPrompt['status']): boolean { - return status === 'completed' || status === 'failed' || status === 'aborted' || status === 'blocked'; -} - -function epochMsToIso(value: number): string { - return new Date(value).toISOString(); -} - -function restOf(event: { readonly type: string; readonly time?: number }): Record { - const { type: _type, time: _time, ...rest } = event; - return rest; -} - -function mapTurnOrigin(origin: unknown): TurnOrigin { - const candidate = origin as { kind?: unknown } | null | undefined; - const kind = typeof candidate?.kind === 'string' ? candidate.kind : undefined; - switch (kind) { - case 'user': - return { kind: 'user', payload: origin }; - case 'cron_job': - case 'cron_missed': { - const jobId = (candidate as { jobId?: unknown }).jobId; - return { - kind: 'cron', - taskId: typeof jobId === 'string' ? jobId : undefined, - payload: origin, - }; - } - case 'task': - case 'background_task': { - const taskId = (candidate as { taskId?: unknown }).taskId; - return typeof taskId === 'string' - ? { kind: 'task', taskId, payload: origin } - : { kind: 'other', payload: origin }; - } - case 'hook_result': - return { kind: 'hook', payload: origin }; - case 'compaction_summary': - return { kind: 'compaction', payload: origin }; - case 'shell_command': - return { kind: 'user', payload: origin }; - default: - return { kind: 'other', payload: origin }; - } -} - -function mapTurnEndState(reason: 'completed' | 'cancelled' | 'failed' | 'blocked'): TurnState { - switch (reason) { - case 'completed': - return 'completed'; - case 'cancelled': - return 'cancelled'; - case 'failed': - case 'blocked': - return 'failed'; - } -} - -function mapTaskKind(kind: string): TranscriptTask['kind'] { - switch (kind) { - case 'process': - return 'shell'; - case 'agent': - return 'subagent'; - default: - return 'other'; - } -} - -function mapInteractionEndState( - kind: 'approval' | 'question', - response: unknown, -): TranscriptInteraction['state'] { - if (kind === 'question') return response === null ? 'dismissed' : 'answered'; - const decision = (response as { decision?: unknown } | null | undefined)?.decision; - if (decision === 'approved' || decision === 'rejected' || decision === 'cancelled') { - return decision; - } - return 'cancelled'; -} - -const TODO_LIST_TOOL_NAME = 'TodoList'; -const TODO_ENTITY_ID = 'todo'; - -function todoWriteItems(input: unknown): TranscriptTodo['items'] | undefined { - const todos = (input as { todos?: unknown } | undefined)?.todos; - if (!Array.isArray(todos)) return undefined; - const items: { title: string; status: 'pending' | 'in_progress' | 'done' }[] = []; - for (const entry of todos) { - const title = (entry as { title?: unknown } | undefined)?.title; - const status = (entry as { status?: unknown } | undefined)?.status; - if (typeof title !== 'string') return undefined; - if (status !== 'pending' && status !== 'in_progress' && status !== 'done') return undefined; - items.push({ title, status }); - } - return items; -} - -function parseToolArgs(args: unknown): unknown { - if (typeof args !== 'string' || args.length === 0) return args; - try { - return JSON.parse(args) as unknown; - } catch { - return args; - } -} diff --git a/packages/kap-server/src/services/transcript/index.ts b/packages/kap-server/src/services/transcript/index.ts deleted file mode 100644 index 0e2ebefd196..00000000000 --- a/packages/kap-server/src/services/transcript/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './coreEventMap'; -export * from './coreBinding'; -export * from './transcriptService'; diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts deleted file mode 100644 index 887f1c1c90f..00000000000 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ /dev/null @@ -1,754 +0,0 @@ -import { join } from 'node:path'; -import { readFile } from 'node:fs/promises'; - -import { - IAgentLifecycleService, - IAgentPromptService, - IFlagService, - ISessionIndex, - ISessionManager, - ISessionMetadata, - IAgentLoopService, - TOWER_FLAG_ID, - followSessionLifecycles, - getLiveSessionById, - isTowerFeatureAssembled, - isUndoAnchor, - reduceContextTranscript, - type ContextMessage, - type IDisposable, - type Scope, - type SessionMeta, -} from '@moonshot-ai/agent-core-v2'; -import { - TowerStore, - resolveTowerRepoRoot, -} from '@moonshot-ai/agent-core-v2/features/tower/protocol/index'; -import { - TranscriptStore, - foldWireRecordFacts, - groupMessagesIntoSnapshot, - isPlainAgentId, - type AgentDescriptor, - type ActivityMeta, - type AgentTranscript, - type AgentTranscriptSnapshot, - type TranscriptChangeEvent, - type TranscriptMarker, - type TranscriptOperation, - type TranscriptTaskRef, - type TranscriptTurn, -} from '@moonshot-ai/transcript'; - -import { readWireRecords, type ContextRecord } from './wireRecords'; -import { toWireQuestion } from '../../protocol/question-wire'; -import { projectPromptContentParts } from '../messages/messageProjection'; -import { - bindSessionTranscript, - descriptorFromMeta, - type TranscriptBinding, - type TranscriptBindingLogger, -} from './coreBinding'; - -const SESSIONS_ROOT = 'sessions'; -const AGENTS_DIR = 'agents'; -const MAIN_AGENT_ID = 'main'; -const WIRE_FILE = 'wire.jsonl'; -const STATE_FILE = 'state.json'; - -export interface TranscriptServiceDeps { - readonly homeDir: string; - readonly core: Scope; - readonly logger?: TranscriptBindingLogger; -} - -interface LiveEntry { - readonly store: TranscriptStore; - readonly binding: TranscriptBinding; - readonly ready: Promise; - readonly agentBackfills: Map>; - readonly opsJournals: Map; -} - -interface AgentOpsJournal { - nextSeq: number; - batches: { seq: number; ops: TranscriptOperation[] }[]; -} - -export const TRANSCRIPT_OPS_JOURNAL_CAPACITY = 2000; - -export interface TranscriptOpsCatchup { - readonly batches: readonly { seq: number; ops: readonly TranscriptOperation[] }[]; - readonly latestSeq: number; - readonly complete: boolean; -} - -export class TranscriptService { - private readonly live = new Map(); - private readonly opsListeners = new Map< - string, - Set<(event: TranscriptChangeEvent, seq: number) => void> - >(); - private readonly healTimers = new Map; timer: NodeJS.Timeout }>(); - - constructor(private readonly deps: TranscriptServiceDeps) { - followSessionLifecycles(deps.core.accessor, (service) => { - const d1 = service.onDidCloseSession(({ sessionId }) => this.dropSession(sessionId)); - const d2 = service.onDidArchiveSession(({ sessionId }) => this.dropSession(sessionId)); - return { - dispose: () => { - d1.dispose(); - d2.dispose(); - }, - }; - }); - } - - forSessionLive(sessionId: string): TranscriptStore | undefined { - const existing = this.live.get(sessionId); - if (existing !== undefined) { - if (getLiveSessionById(this.deps.core.accessor, sessionId) !== undefined) { - return existing.store; - } - this.dropSession(sessionId); - return undefined; - } - const session = getLiveSessionById(this.deps.core.accessor, sessionId); - if (session === undefined) return undefined; - const store = new TranscriptStore(sessionId); - let binding: TranscriptBinding; - try { - binding = bindSessionTranscript(store, session, this.deps.logger, (event) => - this.handleLiveOps(sessionId, event), - ); - } catch (error) { - if (error instanceof Error && error.message === 'InstantiationService has been disposed') { - return undefined; - } - throw error; - } - this.live.set(sessionId, { - store, - binding, - ready: (async () => { - await this.backfillMain(sessionId, store); - if (this.live.get(sessionId)?.store === store) { - binding.seedPendingInteractions(MAIN_AGENT_ID); - } - })(), - agentBackfills: new Map(), - opsJournals: new Map(), - }); - return store; - } - - async whenReady(sessionId: string): Promise { - await this.live.get(sessionId)?.ready; - } - - async ensureAgentHistory(sessionId: string, agentId: string): Promise { - if (agentId === MAIN_AGENT_ID) return this.whenReady(sessionId); - const entry = this.live.get(sessionId); - if (entry === undefined) return; - await entry.ready; - let backfill = entry.agentBackfills.get(agentId); - if (backfill === undefined) { - backfill = this.backfillAgent(sessionId, entry.store, agentId); - entry.agentBackfills.set(agentId, backfill); - } - await backfill; - if (this.live.get(sessionId)?.store === entry.store) { - entry.binding.seedPendingInteractions(agentId); - } - } - - private async backfillMain(sessionId: string, store: TranscriptStore): Promise { - await this.backfillAgent(sessionId, store, MAIN_AGENT_ID); - if (this.live.get(sessionId)?.store !== store) return; - try { - const session = getLiveSessionById(this.deps.core.accessor, sessionId); - const meta = await session?.accessor.get(ISessionMetadata).read(); - for (const [agentId, agentMeta] of Object.entries(meta?.agents ?? {})) { - store.describeAgent(descriptorFromMeta(agentId, agentMeta)); - } - } catch { - } - } - - private async backfillAgent(sessionId: string, store: TranscriptStore, agentId: string): Promise { - let snapshot: AgentTranscriptSnapshot | undefined; - try { - snapshot = await this.readColdSnapshot(sessionId, agentId); - } catch (error) { - this.deps.logger?.warn( - { sessionId, agentId, err: error instanceof Error ? error.message : error }, - 'transcript: history backfill failed, continuing without it', - ); - } - if (this.live.get(sessionId)?.store !== store) return; - const transcript = store.ensureAgent(agentId); - if (snapshot !== undefined) { - const superseded = supersededColdAttachmentIds(snapshot, transcript); - const ops = snapshotToOps(snapshot, (turn) => - healTurnOps(turn, transcript.getTurn(turn.turnId)), - ).filter( - (op) => op.op !== 'attachment.upsert' || !superseded.has(op.attachment.attachmentId), - ); - const overlay = this.liveTurnOverlay(sessionId, agentId, transcript, snapshot); - if (overlay !== undefined) ops.push(overlay, { op: 'meta.merge', meta: { activity: 'turn' } }); - ops.push(...this.livePromptBackfill(sessionId, agentId)); - const result = transcript.apply(ops); - if (result.gap !== undefined) { - this.deps.logger?.warn({ sessionId, agentId, gap: result.gap }, 'transcript: backfill append gap'); - } - this.dispatchOps(sessionId, { agentId, ops }); - } - const existing = store.agents().find((d) => d.agentId === agentId); - const hasContent = - snapshot !== undefined && (snapshot.items.length > 0 || snapshot.tasks.length > 0); - if (existing !== undefined || hasContent) { - store.describeAgent({ - agentId, - type: existing?.type ?? (agentId === MAIN_AGENT_ID ? 'main' : 'sub'), - parentAgentId: existing?.parentAgentId, - label: existing?.label, - createdAt: existing?.createdAt, - }); - } - } - - onSessionOps( - sessionId: string, - listener: (event: TranscriptChangeEvent, seq: number) => void, - ): IDisposable | undefined { - if (this.forSessionLive(sessionId) === undefined) return undefined; - let listeners = this.opsListeners.get(sessionId); - if (listeners === undefined) { - listeners = new Set(); - this.opsListeners.set(sessionId, listeners); - } - listeners.add(listener); - return { - dispose: () => { - const entry = this.opsListeners.get(sessionId); - if (entry === undefined) return; - entry.delete(listener); - if (entry.size === 0) this.opsListeners.delete(sessionId); - }, - }; - } - - private dispatchOps(sessionId: string, event: TranscriptChangeEvent): void { - const seq = this.journalOps(sessionId, event); - const listeners = this.opsListeners.get(sessionId); - if (listeners === undefined) return; - for (const listener of listeners) { - try { - listener(event, seq); - } catch { - } - } - } - - private journalOps(sessionId: string, event: TranscriptChangeEvent): number { - const entry = this.live.get(sessionId); - if (entry === undefined) return 0; - let journal = entry.opsJournals.get(event.agentId); - if (journal === undefined) { - journal = { nextSeq: 1, batches: [] }; - entry.opsJournals.set(event.agentId, journal); - } - const seq = journal.nextSeq++; - journal.batches.push({ seq, ops: [...event.ops] }); - if (journal.batches.length > TRANSCRIPT_OPS_JOURNAL_CAPACITY) journal.batches.shift(); - return seq; - } - - getSeqWatermark(sessionId: string, agentId: string): number { - const journal = this.live.get(sessionId)?.opsJournals.get(agentId); - return journal === undefined ? 0 : journal.nextSeq - 1; - } - - getOpsSince( - sessionId: string, - agentId: string, - sinceSeq: number, - ): TranscriptOpsCatchup | undefined { - if (this.forSessionLive(sessionId) === undefined) return undefined; - const journal = this.live.get(sessionId)?.opsJournals.get(agentId); - const latestSeq = journal === undefined ? 0 : journal.nextSeq - 1; - if (sinceSeq > latestSeq) return { batches: [], latestSeq, complete: false }; - const batches = journal?.batches.filter((batch) => batch.seq > sinceSeq) ?? []; - const oldest = journal?.batches[0]?.seq; - const complete = batches.length === 0 || (oldest !== undefined && oldest <= sinceSeq + 1); - return { batches, latestSeq, complete }; - } - - private handleLiveOps(sessionId: string, event: TranscriptChangeEvent): void { - this.dispatchOps(sessionId, event); - for (const op of event.ops) { - if (op.op === 'turn.upsert' && TERMINAL_TURN_STATES.has(op.turn.state)) { - this.scheduleTurnHeal(sessionId, event.agentId, op.turn.ordinal); - } - } - } - - private scheduleTurnHeal(sessionId: string, agentId: string, ordinal: number): void { - const key = `${sessionId}:${agentId}`; - const existing = this.healTimers.get(key); - if (existing !== undefined) { - existing.ordinals.add(ordinal); - existing.timer.refresh(); - return; - } - const ordinals = new Set([ordinal]); - const timer = setTimeout(() => { - this.healTimers.delete(key); - void this.healEndedTurns(sessionId, agentId, ordinals); - }, TURN_HEAL_DEBOUNCE_MS); - timer.unref(); - this.healTimers.set(key, { ordinals, timer }); - } - - private liveTurnOverlay( - sessionId: string, - agentId: string, - transcript: AgentTranscript, - snapshot: AgentTranscriptSnapshot, - ): TranscriptOperation | undefined { - const session = getLiveSessionById(this.deps.core.accessor, sessionId); - const agent = - session === undefined - ? undefined - : session.accessor.get(IAgentLifecycleService).handleOf(agentId); - const status = agent?.accessor.get(IAgentLoopService).status(); - if (status?.state !== 'running' || status.activeTurnId === undefined) return undefined; - const promptService = agent?.accessor.get(IAgentPromptService); - const activePromptId = promptService?.list().active?.id; - const ordinal = status.activeTurnId; - const turnId = `t${ordinal}`; - const existing = transcript.getTurn(turnId); - const snapshotTurn = snapshot.items.find( - (item): item is TranscriptTurn => item.kind === 'turn' && item.ordinal === ordinal, - ); - return { - op: 'turn.upsert', - turn: { - kind: 'turn', - turnId, - ordinal, - state: 'running', - triggerPromptId: existing?.triggerPromptId ?? snapshotTurn?.triggerPromptId ?? activePromptId, - origin: existing?.origin ?? snapshotTurn?.origin ?? { kind: 'other' }, - prompt: existing?.prompt ?? snapshotTurn?.prompt, - attachmentIds: existing?.attachmentIds ?? snapshotTurn?.attachmentIds, - startedAt: existing?.startedAt ?? snapshotTurn?.startedAt, - }, - }; - } - - private livePromptBackfill(sessionId: string, agentId: string): TranscriptOperation[] { - const agent = getLiveSessionById(this.deps.core.accessor, sessionId) - ?.accessor.get(IAgentLifecycleService) - .handleOf(agentId); - const promptService = agent === undefined ? undefined : agent.accessor.get(IAgentPromptService); - const queue = promptService?.list(); - if (queue === undefined) return []; - const ops: TranscriptOperation[] = []; - if (queue.active !== undefined) { - ops.push({ - op: 'prompt.upsert', - prompt: { - promptId: queue.active.id, - status: 'running', - userMessageId: queue.active.userMessageId, - content: projectPromptContentParts(queue.active.message.content), - createdAt: queue.active.createdAt, - }, - }); - } - for (const pending of queue.pending) { - ops.push({ - op: 'prompt.upsert', - prompt: { - promptId: pending.id, - status: 'queued', - userMessageId: pending.userMessageId, - content: projectPromptContentParts(pending.message.content), - createdAt: pending.createdAt, - }, - }); - } - return ops; - } - - private async healEndedTurns( - sessionId: string, - agentId: string, - ordinals: ReadonlySet, - ): Promise { - const entry = this.live.get(sessionId); - if (entry === undefined) return; - let snapshot: AgentTranscriptSnapshot | undefined; - try { - snapshot = await this.readColdSnapshot(sessionId, agentId); - } catch (error) { - this.deps.logger?.warn( - { sessionId, agentId, err: error instanceof Error ? error.message : error }, - 'transcript: post-turn heal failed, continuing without it', - ); - return; - } - if (snapshot === undefined || this.live.get(sessionId)?.store !== entry.store) return; - const transcript = entry.store.getAgent(agentId); - if (transcript === undefined) return; - const turnOps: TranscriptOperation[] = []; - for (const item of snapshot.items) { - if (item.kind !== 'turn' || !ordinals.has(item.ordinal)) continue; - turnOps.push(...healTurnOps(item, transcript.getTurn(item.turnId))); - } - if (turnOps.length === 0) return; - const superseded = supersededColdAttachmentIds(snapshot, transcript); - const ops: TranscriptOperation[] = [ - ...snapshot.attachments - .filter((attachment) => !superseded.has(attachment.attachmentId)) - .map((attachment) => ({ - op: 'attachment.upsert' as const, - attachment, - })), - ...turnOps, - ]; - transcript.apply(ops); - this.dispatchOps(sessionId, { agentId, ops }); - } - - async readColdRoster(sessionId: string): Promise { - const summary = await this.deps.core.accessor.get(ISessionIndex).get(sessionId); - if (summary === undefined) return undefined; - let meta: SessionMeta; - try { - const raw = await readFile( - join(this.deps.homeDir, SESSIONS_ROOT, summary.workspaceId, sessionId, STATE_FILE), - 'utf-8', - ); - meta = JSON.parse(raw) as SessionMeta; - } catch { - return []; - } - return Object.entries(meta.agents ?? {}).map(([agentId, agentMeta]) => - descriptorFromMeta(agentId, agentMeta), - ); - } - - async readColdSnapshot( - sessionId: string, - agentId: string = MAIN_AGENT_ID, - ): Promise { - const summary = await this.deps.core.accessor.get(ISessionIndex).get(sessionId); - if (summary === undefined) return undefined; - if (!isPlainAgentId(agentId)) { - return groupMessagesIntoSnapshot([]); - } - const wirePath = join( - this.deps.homeDir, - SESSIONS_ROOT, - summary.workspaceId, - sessionId, - AGENTS_DIR, - agentId, - WIRE_FILE, - ); - let records: Awaited>; - try { - records = await readWireRecords(wirePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return groupMessagesIntoSnapshot([]); - } - throw error; - } - const messages = [...reduceContextTranscript(records).entries]; - const taskOriginTurnTaskIds = new Set(); - const steeredContents = new Map>(); - const anchorStack: { taskIdsSnapshot: Set }[] = []; - let anchorFloor = 0; - let sawTurnPrompt = false; - for (const record of records) { - if (record.type === 'context.undo') { - const count = typeof record['count'] === 'number' ? (record['count'] as number) : 0; - for (let i = 0; i < count && anchorStack.length > anchorFloor; i++) { - const popped = anchorStack.pop()!; - taskOriginTurnTaskIds.clear(); - for (const id of popped.taskIdsSnapshot) taskOriginTurnTaskIds.add(id); - } - continue; - } - if (record.type === 'context.clear') { - anchorFloor = anchorStack.length; - continue; - } - if (record.type === 'context.append_message') { - const message = (record as { message?: ContextMessage }).message; - if (message !== undefined && isUndoAnchor(message)) { - anchorStack.push({ taskIdsSnapshot: new Set(taskOriginTurnTaskIds) }); - } - continue; - } - if (record.type === 'turn.steer') { - const input = record['input']; - if (Array.isArray(input)) { - const key = JSON.stringify(input); - const steerOrigin = (record as { origin?: { kind?: unknown } }).origin?.kind; - const kind = typeof steerOrigin === 'string' ? steerOrigin : 'user'; - const byKind = steeredContents.get(key) ?? new Map(); - byKind.set(kind, (byKind.get(kind) ?? 0) + 1); - steeredContents.set(key, byKind); - } - continue; - } - if (record.type !== 'turn.prompt') continue; - sawTurnPrompt = true; - const origin = (record as { origin?: { kind?: unknown; taskId?: unknown } }).origin; - if (origin === undefined) continue; - if ( - (origin.kind === 'task' || origin.kind === 'background_task') && - typeof origin.taskId === 'string' - ) { - taskOriginTurnTaskIds.add(origin.taskId); - } - } - const base = groupMessagesIntoSnapshot( - messages, - sawTurnPrompt || steeredContents.size > 0 ? { taskOriginTurnTaskIds, steeredContents } : undefined, - ); - const folded = foldWireRecordFacts(projectQuestionInteractionRecords(records, sessionId), base, { - resolvePlanRevisionKey: (key) => - join(SESSIONS_ROOT, summary.workspaceId, sessionId, AGENTS_DIR, agentId, key), - }); - const status = getLiveSessionById(this.deps.core.accessor, sessionId) - ?.accessor.get(IAgentLifecycleService) - .handleOf(agentId) - ?.accessor.get(IAgentLoopService) - .status(); - const activity: ActivityMeta = status?.state === 'running' ? 'turn' : 'idle'; - const snapshot = { ...folded, meta: { ...folded.meta, activity } }; - if (snapshot.meta.modes?.tower === undefined) return snapshot; - const flags = this.deps.core.accessor.get(IFlagService); - if ( - agentId === MAIN_AGENT_ID && - flags.enabled(TOWER_FLAG_ID) && - isTowerFeatureAssembled(flags) && - (await this.coldTowerOwnedHere(sessionId, summary.cwd)) - ) { - return snapshot; - } - const modes = { ...snapshot.meta.modes, tower: undefined }; - const cleared = modes.plan === undefined && modes.swarm === undefined && modes.tower === undefined; - return { ...snapshot, meta: { ...snapshot.meta, modes: cleared ? undefined : modes } }; - } - - private async coldTowerOwnedHere(sessionId: string, cwd: string | undefined): Promise { - if (cwd === undefined) return true; - const owner = await new TowerStore(resolveTowerRepoRoot(cwd)) - .load() - .then((state) => state.sessionId, () => undefined); - if (owner === undefined || owner === sessionId) return true; - return this.deps.core.accessor.get(ISessionManager).get(owner) === undefined; - } - - dropSession(sessionId: string): void { - this.opsListeners.delete(sessionId); - for (const [key, pending] of this.healTimers) { - if (key.startsWith(`${sessionId}:`)) { - clearTimeout(pending.timer); - this.healTimers.delete(key); - } - } - const entry = this.live.get(sessionId); - if (entry === undefined) return; - this.live.delete(sessionId); - entry.binding.dispose(); - } -} - -export function snapshotToOps( - snapshot: AgentTranscriptSnapshot, - turnOps: (turn: TranscriptTurn) => TranscriptOperation[] = snapshotTurnOps, -): TranscriptOperation[] { - const ops: TranscriptOperation[] = []; - const pending: (TranscriptMarker | TranscriptTaskRef)[] = []; - let lastTurnOrdinal: number | undefined; - const flushPending = (beforeTurn?: number): void => { - for (const item of pending) { - ops.push( - item.kind === 'marker' - ? { op: 'marker.upsert', item, beforeTurn } - : { op: 'taskref.upsert', item, beforeTurn }, - ); - } - pending.length = 0; - }; - for (const item of snapshot.items) { - if (item.kind === 'turn') { - flushPending(item.ordinal); - lastTurnOrdinal = item.ordinal; - ops.push(...turnOps(item)); - } else { - pending.push(item); - } - } - flushPending(lastTurnOrdinal === undefined ? undefined : lastTurnOrdinal + 1); - for (const attachment of snapshot.attachments) { - ops.push({ op: 'attachment.upsert', attachment }); - } - for (const task of snapshot.tasks) { - ops.push({ op: 'task.upsert', task }); - } - ops.push({ op: 'meta.merge', meta: snapshot.meta }); - return ops; -} - -export function snapshotTurnOps(turn: TranscriptTurn): TranscriptOperation[] { - const ops: TranscriptOperation[] = []; - const { steps, ...header } = turn; - ops.push({ op: 'turn.upsert', turn: header }); - for (const step of steps) { - const { frames, ...stepHeader } = step; - ops.push({ op: 'step.upsert', turnId: turn.turnId, step: stepHeader }); - for (const frame of frames) { - ops.push({ op: 'frame.upsert', turnId: turn.turnId, stepId: step.stepId, frame }); - } - } - return ops; -} - -const TURN_HEAL_DEBOUNCE_MS = 250; -const TERMINAL_TURN_STATES: ReadonlySet = new Set([ - 'completed', - 'failed', - 'cancelled', -]); - -function projectQuestionInteractionRecords( - records: readonly ContextRecord[], - sessionId: string, -): ContextRecord[] { - return records.map((record) => { - if (record.type !== 'interaction.request' || record['kind'] !== 'question') return record; - const id = record['id']; - const request = record['request']; - const time = record['time']; - if (typeof id !== 'string' || typeof time !== 'number' || !Number.isFinite(time)) { - return record; - } - if (request === null || typeof request !== 'object') return record; - try { - const innerToolCallId = (request as { toolCallId?: unknown }).toolCallId; - const toolCallId = - typeof record['toolCallId'] === 'string' - ? record['toolCallId'] - : typeof innerToolCallId === 'string' - ? innerToolCallId - : undefined; - return { - ...record, - toolCallId, - request: toWireQuestion({ id, createdAt: time, payload: request }, sessionId), - }; - } catch { - return record; - } - }); -} - -function supersededColdAttachmentIds( - snapshot: AgentTranscriptSnapshot, - transcript: AgentTranscript, -): ReadonlySet { - const superseded = new Set(); - for (const item of snapshot.items) { - if (item.kind !== 'turn' || item.attachmentIds === undefined) continue; - const live = transcript.getTurn(item.turnId); - if (live?.attachmentIds === undefined || live.attachmentIds.length === 0) continue; - for (const id of item.attachmentIds) superseded.add(id); - } - return superseded; -} - -export function healTurnOps( - snapshotTurn: TranscriptTurn, - liveTurn: TranscriptTurn | undefined, -): TranscriptOperation[] { - const { steps, ...header } = snapshotTurn; - const ops: TranscriptOperation[] = []; - if (liveTurn === undefined) { - ops.push({ op: 'turn.upsert', turn: header }); - for (const step of steps) { - const { frames, ...stepHeader } = step; - ops.push({ op: 'step.upsert', turnId: snapshotTurn.turnId, step: stepHeader }); - for (const frame of frames) { - ops.push({ op: 'frame.upsert', turnId: snapshotTurn.turnId, stepId: step.stepId, frame }); - } - } - return ops; - } - ops.push({ - op: 'turn.upsert', - turn: { - ...header, - state: liveTurn.state, - triggerPromptId: liveTurn.triggerPromptId ?? header.triggerPromptId, - prompt: liveTurn.prompt ?? header.prompt, - attachmentIds: liveTurn.attachmentIds ?? header.attachmentIds, - startedAt: liveTurn.startedAt ?? header.startedAt, - endedAt: liveTurn.endedAt ?? header.endedAt, - }, - }); - for (const step of steps) { - const liveStep = liveTurn.steps.find((entry) => entry.stepId === step.stepId); - const { frames, ...stepHeader } = step; - if (liveStep === undefined) { - ops.push({ op: 'step.upsert', turnId: snapshotTurn.turnId, step: stepHeader }); - for (const frame of frames) { - ops.push({ op: 'frame.upsert', turnId: snapshotTurn.turnId, stepId: step.stepId, frame }); - } - continue; - } - for (const frame of frames) { - const liveFrame = liveStep.frames.find((entry) => entry.frameId === frame.frameId); - if (frame.kind === 'tool') { - const liveTool = liveFrame?.kind === 'tool' ? liveFrame : undefined; - const liveHasOutcome = - liveTool !== undefined && (liveTool.output !== undefined || liveTool.error !== undefined); - const snapshotHasOutcome = frame.output !== undefined || frame.error !== undefined; - if (liveTool !== undefined && (liveHasOutcome || !snapshotHasOutcome)) continue; - ops.push({ - op: 'frame.upsert', - turnId: snapshotTurn.turnId, - stepId: step.stepId, - frame: - liveTool === undefined - ? frame - : { - ...frame, - display: liveTool.display ?? frame.display, - agentRefs: liveTool.agentRefs ?? frame.agentRefs, - approvalId: liveTool.approvalId ?? frame.approvalId, - }, - }); - continue; - } - if (frame.kind !== 'text' && frame.kind !== 'thinking') continue; - if ( - liveFrame !== undefined && - liveFrame.kind === frame.kind && - (liveFrame.kind === 'text' || liveFrame.kind === 'thinking') && - liveFrame.text.length >= frame.text.length - ) { - continue; - } - ops.push({ op: 'frame.upsert', turnId: snapshotTurn.turnId, stepId: step.stepId, frame }); - } - } - return ops; -} diff --git a/packages/kap-server/src/services/transcript/wireRecords.ts b/packages/kap-server/src/services/transcript/wireRecords.ts deleted file mode 100644 index 53fdd245a33..00000000000 --- a/packages/kap-server/src/services/transcript/wireRecords.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { readFile } from 'node:fs/promises'; - -export interface ContextRecord { - readonly type: string; - readonly [key: string]: unknown; -} - -export async function readWireRecords(wirePath: string): Promise { - const raw = await readFile(wirePath, 'utf8'); - const lines = raw.split('\n'); - const records: ContextRecord[] = []; - for (let i = 0; i < lines.length; i++) { - let line = lines[i]!; - if (line.endsWith('\r')) line = line.slice(0, -1); - if (line.length === 0) continue; - try { - records.push(JSON.parse(line) as ContextRecord); - } catch (parseError) { - if (i === lines.length - 1) break; - throw new Error( - `wire.jsonl: corrupted line ${i + 1} in ${wirePath}: ${String(parseError)}`, - { cause: parseError }, - ); - } - } - return records; -} diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index bc8d9f4f8bd..960fcccb185 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -31,7 +31,6 @@ import { kimiRegionProfile, type KimiHostIdentity, } from '@moonshot-ai/kimi-code-oauth'; -import { createAsyncApiDocument } from './protocol/asyncapi'; import Fastify, { type FastifyInstance } from 'fastify'; import { installErrorHandler } from './error-handler'; @@ -57,9 +56,6 @@ import { type IConnectionRegistry, } from './transport/ws/connectionRegistry'; import { extractWsBearerToken } from './transport/ws/bearerProtocol'; -import { SessionEventBroadcaster } from './transport/ws/v1/sessionEventBroadcaster'; -import type { ConfigWarningItem } from './transport/ws/v1/events'; -import { registerWsV1, WS_PATH as WS_PATH_V1 } from './transport/ws/v1/registerWsV1'; import { registerWsDebug, WS_DEBUG_PATH } from './transport/ws/debug/registerWsDebug'; import { registerWsV3, WS_PATH_V3 } from './transport/ws/v3/registerWsV3'; import { getServerVersion } from './version'; @@ -78,7 +74,6 @@ import { type ServerTelemetry, shutdownServerTelemetry, } from './services/telemetry'; -import { TranscriptService } from './services/transcript/transcriptService'; import { ProjectionService } from './services/projection'; import { ModelCatalogRefreshScheduler } from './services/modelCatalog/modelCatalogRefreshScheduler'; import { startConfigChangedPublisher } from './services/config/configChangedPublisher'; @@ -91,6 +86,7 @@ import { resolvePasswordHash } from './services/auth/password'; import { createTokenStore } from './services/auth/tokenStore'; import { drainGlobalSearchDisposals, IGlobalSearchService } from './search/searchService'; +import { LocalLiveTranscriptSource } from './search/liveSource'; export interface ServerHostIdentity extends KimiHostIdentity { readonly displayName?: string; @@ -348,19 +344,14 @@ export async function startServer(opts: ServerStartOptions): Promise { - const warnings: ConfigWarningItem[] = diagnostics + const warnings = diagnostics .filter((diagnostic) => diagnostic.severity === 'warning') .map((diagnostic) => diagnostic.domain === undefined @@ -411,9 +402,7 @@ export async function startServer(opts: ServerStartOptions): Promise logger.error({ err }, 'server close failed')); }, connectionRegistry, - broadcaster, - transcriptService, homeDir, projectionService, dangerousBypassAuth: opts.disableAuth === true, @@ -476,12 +463,6 @@ export async function startServer(opts: ServerStartOptions): Promise => { const url = req.url ?? ''; - const isV1 = url === WS_PATH_V1 || url.startsWith(`${WS_PATH_V1}?`); const isV3 = url === WS_PATH_V3 || url.startsWith(`${WS_PATH_V3}?`); const isDebug = url === WS_DEBUG_PATH || url.startsWith(`${WS_DEBUG_PATH}?`); - const wss = isV1 ? wssV1 : isV3 ? wssV3 : isDebug ? wssDebug : undefined; + const wss = isV3 ? wssV3 : isDebug ? wssDebug : undefined; if (wss === undefined) { socket.destroy(); return; @@ -573,17 +553,9 @@ export async function startServer(opts: ServerStartOptions): Promise { connectionRegistry.closeAll('server shutting down'); - wssV1.close(); wssDebug.close(); wssV3.close(); wsV3Hub.dispose(); - await broadcaster.close(); - }); - - app.get('/asyncapi.json', async (_req, reply) => { - return reply - .type('application/json') - .send(createAsyncApiDocument({ version: serverVersion, serverHost: host })); }); app.get('/openapi.json', async (_req, reply) => { diff --git a/packages/kap-server/src/transport/ws/v1/events.ts b/packages/kap-server/src/transport/ws/v1/events.ts deleted file mode 100644 index 4d03735a0b6..00000000000 --- a/packages/kap-server/src/transport/ws/v1/events.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type { z } from 'zod'; - -import type { agentEventSchema } from '../../../protocol/events-zod'; -import type { MessageContent } from '../../../protocol/message'; -import type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; -import type { UsageStatus } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; -import type { AgentPhase } from '../../../services/legacyStatus/legacyStatus'; -import type { ConfigResponse } from '../../../protocol/rest-config'; -import type { Session, SessionPendingInteraction } from '../../../protocol/session'; -import type { Workspace } from '../../../protocol/workspace'; - -export interface AgentStatusUpdatedEvent { - readonly type: 'agent.status.updated'; - readonly model?: string; - readonly thinkingEffort?: string; - readonly contextTokens?: number; - readonly maxContextTokens?: number; - readonly contextUsage?: number; - readonly planMode?: boolean; - readonly swarmMode?: boolean; - readonly towerMode?: boolean; - readonly permission?: PermissionMode; - readonly usage?: UsageStatus; - readonly phase?: AgentPhase; -} - -export interface AgentCreatedEvent { - readonly type: 'agent.created'; -} - -export interface AgentDisposedEvent { - readonly type: 'agent.disposed'; -} - -export interface SessionMetaUpdatedEvent { - readonly type: 'session.meta.updated'; - readonly title?: string; - readonly patch?: Record; -} - -export interface SessionCreatedEvent { - readonly type: 'event.session.created'; - readonly session: Session; -} - -export interface SessionArchivedEvent { - readonly type: 'event.session.archived'; - readonly workspace_id: string; -} - -export interface SessionDeletedEvent { - readonly type: 'event.session.deleted'; - readonly workspace_id: string; -} - -export interface WorkspaceCreatedEvent { - readonly type: 'event.workspace.created'; - readonly workspace: Workspace; -} - -export interface WorkspaceUpdatedEvent { - readonly type: 'event.workspace.updated'; - readonly workspace: Workspace; -} - -export interface WorkspaceDeletedEvent { - readonly type: 'event.workspace.deleted'; - readonly workspace_id: string; - readonly root: string; -} - -export interface SessionWorkChangedEvent { - readonly type: 'event.session.work_changed'; - readonly busy: boolean; - readonly main_turn_active?: boolean; - readonly pending_interaction?: SessionPendingInteraction; - readonly last_turn_reason?: 'completed' | 'cancelled' | 'failed'; -} - -type LegacySessionStatus = - | 'idle' - | 'running' - | 'awaiting_approval' - | 'awaiting_question' - | 'aborted'; - -export interface SessionStatusChangedEvent { - readonly type: 'event.session.status_changed'; - readonly status: LegacySessionStatus; - readonly previous_status: LegacySessionStatus; - readonly current_prompt_id?: string; -} - -export interface ConfigChangedEvent { - readonly type: 'event.config.changed'; - readonly changedFields: string[]; - readonly config: ConfigResponse; -} - -export interface ConfigWarningItem { - readonly domain?: string; - readonly message: string; -} - -export interface ConfigWarningEvent { - readonly type: 'event.config.warning'; - readonly warnings: readonly ConfigWarningItem[]; -} - -export interface ModelCatalogRefreshChange { - readonly provider_id: string; - readonly provider_name: string; - readonly added: number; - readonly removed: number; -} - -export interface ModelCatalogRefreshFailure { - readonly provider: string; - readonly reason: string; -} - -export interface ModelCatalogChangedEvent { - readonly type: 'event.model_catalog.changed'; - readonly changed: readonly ModelCatalogRefreshChange[]; - readonly unchanged: readonly string[]; - readonly failed: readonly ModelCatalogRefreshFailure[]; -} - -export interface PluginChangedEvent { - readonly type: 'event.plugin.changed'; -} - -export interface CapabilityChangedEvent { - readonly type: 'event.capability.changed'; - readonly capability_id: string; - readonly install: { - readonly running: boolean; - readonly step?: string; - readonly percent?: number; - readonly error?: string; - readonly note?: string; - }; -} - -export interface DiUnitChangedEvent { - readonly type: 'event.di.unit_changed'; - readonly scope: string; - readonly token: string; - readonly state: 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed'; - readonly error?: string; -} - -export interface PromptSubmittedEvent { - readonly type: 'prompt.submitted'; - readonly promptId: string; - readonly userMessageId: string; - readonly status: 'running' | 'queued' | 'blocked'; - readonly content: readonly MessageContent[]; - readonly createdAt: string; -} - -export type TaskLifecycleStatus = - | 'running' - | 'completed' - | 'failed' - | 'timed_out' - | 'killed' - | 'lost'; - -export interface TaskInfoBase { - readonly taskId: string; - readonly description: string; - readonly status: TaskLifecycleStatus; - readonly detached?: boolean; - readonly startedAt: number; - readonly endedAt: number | null; - readonly stopReason?: string; - readonly terminalNotificationSuppressed?: boolean; - readonly timeoutMs?: number; -} - -export interface ProcessTaskInfo extends TaskInfoBase { - readonly kind: 'process'; - readonly command: string; - readonly pid: number; - readonly exitCode: number | null; -} - -export interface AgentTaskInfo extends TaskInfoBase { - readonly kind: 'agent'; - readonly agentId?: string; - readonly subagentType?: string; -} - -export interface QuestionTaskInfo extends TaskInfoBase { - readonly kind: 'question'; - readonly questionCount: number; - readonly toolCallId?: string; -} - -export type TaskInfo = - | ProcessTaskInfo - | AgentTaskInfo - | QuestionTaskInfo; - -export interface BackgroundTaskStartedEvent { - readonly type: 'background.task.started'; - readonly info: TaskInfo; -} - -export interface BackgroundTaskTerminatedEvent { - readonly type: 'background.task.terminated'; - readonly info: TaskInfo; -} - -type CoreStreamEvent = z.infer; - -export type AgentEvent = - | CoreStreamEvent - | AgentStatusUpdatedEvent - | AgentCreatedEvent - | AgentDisposedEvent - | SessionMetaUpdatedEvent - | SessionCreatedEvent - | SessionArchivedEvent - | SessionDeletedEvent - | WorkspaceCreatedEvent - | WorkspaceUpdatedEvent - | WorkspaceDeletedEvent - | SessionWorkChangedEvent - | SessionStatusChangedEvent - | ConfigChangedEvent - | ConfigWarningEvent - | ModelCatalogChangedEvent - | PluginChangedEvent - | CapabilityChangedEvent - | DiUnitChangedEvent - | PromptSubmittedEvent - | BackgroundTaskStartedEvent - | BackgroundTaskTerminatedEvent; - -export type Event = AgentEvent & { agentId: string; sessionId: string; readonly time?: number }; - -export const VOLATILE_EVENT_TYPES = [ - 'assistant.delta', - 'thinking.delta', - 'tool.call.delta', - 'tool.progress', - 'shell.output', - 'shell.started', - 'shell.completed', - 'agent.status.updated', - 'event.di.unit_changed', - 'event.capability.changed', -] as const; - -export type VolatileEventType = (typeof VOLATILE_EVENT_TYPES)[number]; - -const volatileEventTypeSet: ReadonlySet = new Set(VOLATILE_EVENT_TYPES); - -export function isVolatileEventType(type: string): type is VolatileEventType { - return volatileEventTypeSet.has(type); -} diff --git a/packages/kap-server/src/transport/ws/v1/inFlightTurnTracker.ts b/packages/kap-server/src/transport/ws/v1/inFlightTurnTracker.ts deleted file mode 100644 index 5f268ce9fd9..00000000000 --- a/packages/kap-server/src/transport/ws/v1/inFlightTurnTracker.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { Event } from './events'; -import type { InFlightToolCall, InFlightTurn } from '../../../protocol/rest-snapshot'; - -const MAIN_AGENT_ID = 'main'; - -interface ToolAccum { - tool_call_id: string; - name: string; - args?: unknown; - description?: string; - display?: unknown; - last_progress?: { - kind: 'stdout' | 'stderr' | 'progress' | 'status' | 'custom'; - text?: string; - percent?: number; - }; -} - -interface TurnAccum { - turnId: number; - assistantText: string; - thinkingText: string; - tools: Map; -} - -export interface VolatileAnnotation { - offset?: number; -} - -export class InFlightTurnTracker { - private readonly bySession = new Map(); - - apply(sessionId: string, event: Event): VolatileAnnotation { - if (event.agentId !== MAIN_AGENT_ID) return {}; - - switch (event.type) { - case 'turn.started': { - this.bySession.set(sessionId, { - turnId: event.turnId, - assistantText: '', - thinkingText: '', - tools: new Map(), - }); - return {}; - } - case 'turn.ended': { - this.bySession.delete(sessionId); - return {}; - } - case 'turn.step.started': { - const turn = this.bySession.get(sessionId); - if (!turn || turn.turnId !== event.turnId) return {}; - turn.assistantText = ''; - turn.thinkingText = ''; - return {}; - } - case 'assistant.delta': { - const turn = this.bySession.get(sessionId); - if (!turn || turn.turnId !== event.turnId) return {}; - const offset = turn.assistantText.length; - turn.assistantText += event.delta; - return { offset }; - } - case 'thinking.delta': { - const turn = this.bySession.get(sessionId); - if (!turn || turn.turnId !== event.turnId) return {}; - const offset = turn.thinkingText.length; - turn.thinkingText += event.delta; - return { offset }; - } - case 'tool.call.started': { - const turn = this.bySession.get(sessionId); - if (!turn || turn.turnId !== event.turnId) return {}; - turn.tools.set(event.toolCallId, { - tool_call_id: event.toolCallId, - name: event.name, - args: event.args, - ...(event.description !== undefined ? { description: event.description } : {}), - ...(event.display !== undefined ? { display: event.display } : {}), - }); - return {}; - } - case 'tool.progress': { - const turn = this.bySession.get(sessionId); - const tool = turn?.tools.get(event.toolCallId); - if (!tool) return {}; - const { kind, text, percent } = event.update; - if (kind === 'custom') return {}; - tool.last_progress = { - kind, - ...(text !== undefined ? { text } : {}), - ...(percent !== undefined ? { percent } : {}), - }; - return {}; - } - case 'tool.result': { - this.bySession.get(sessionId)?.tools.delete(event.toolCallId); - return {}; - } - default: - return {}; - } - } - - get(sessionId: string): InFlightTurn | null { - const turn = this.bySession.get(sessionId); - if (!turn) return null; - const running_tools: InFlightToolCall[] = Array.from(turn.tools.values()).map((t) => ({ - tool_call_id: t.tool_call_id, - name: t.name, - ...(t.args !== undefined ? { args: t.args } : {}), - ...(t.description !== undefined ? { description: t.description } : {}), - ...(t.display !== undefined ? { display: t.display } : {}), - ...(t.last_progress !== undefined ? { last_progress: t.last_progress } : {}), - })); - return { - turn_id: turn.turnId, - assistant_text: turn.assistantText, - thinking_text: turn.thinkingText, - running_tools, - }; - } - - clear(sessionId: string): void { - this.bySession.delete(sessionId); - } -} diff --git a/packages/kap-server/src/transport/ws/v1/protocol.ts b/packages/kap-server/src/transport/ws/v1/protocol.ts deleted file mode 100644 index 3af8dd6fb92..00000000000 --- a/packages/kap-server/src/transport/ws/v1/protocol.ts +++ /dev/null @@ -1,73 +0,0 @@ -export interface ServerHelloPayload { - ws_connection_id: string; - protocol_version: number; - heartbeat_ms: number; - max_event_buffer_size: number; - capabilities: { - event_batching: boolean; - compression: boolean; - }; -} - -export interface ServerHelloFrame { - type: 'server_hello'; - timestamp: string; - payload: ServerHelloPayload; -} - -export function buildServerHello(payload: ServerHelloPayload): ServerHelloFrame { - return { type: 'server_hello', timestamp: new Date().toISOString(), payload }; -} - -export interface PingFrame { - type: 'ping'; - timestamp: string; - payload: { nonce: string }; -} - -export function buildPing(nonce: string): PingFrame { - return { type: 'ping', timestamp: new Date().toISOString(), payload: { nonce } }; -} - -export interface AckFrame

{ - type: 'ack'; - id: string; - code: number; - msg: string; - payload: P; -} - -export function buildAck

(id: string, code: number, msg: string, payload: P): AckFrame

{ - return { type: 'ack', id, code, msg, payload }; -} - -export type ResyncReason = 'buffer_overflow' | 'session_recreated' | 'epoch_changed'; - -export interface ResyncRequiredFrame { - type: 'resync_required'; - timestamp: string; - payload: { - session_id: string; - reason: ResyncReason; - current_seq: number; - epoch?: string; - }; -} - -export function buildResyncRequired( - sessionId: string, - reason: ResyncReason, - currentSeq: number, - epoch?: string, -): ResyncRequiredFrame { - return { - type: 'resync_required', - timestamp: new Date().toISOString(), - payload: { - session_id: sessionId, - reason, - current_seq: currentSeq, - ...(epoch !== undefined ? { epoch } : {}), - }, - }; -} diff --git a/packages/kap-server/src/transport/ws/v1/registerWsV1.ts b/packages/kap-server/src/transport/ws/v1/registerWsV1.ts deleted file mode 100644 index 21e3729a2e7..00000000000 --- a/packages/kap-server/src/transport/ws/v1/registerWsV1.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Scope } from '@moonshot-ai/agent-core-v2'; -import { WebSocketServer } from 'ws'; - -import type { CredentialValidator } from '../../../services/auth/credentials'; -import { type IConnectionRegistry } from '../connectionRegistry'; -import type { SessionEventBroadcaster } from './sessionEventBroadcaster'; -import type { JournalLogger } from './sessionEventJournal'; -import { WsConnectionV1 } from './wsConnectionV1'; -import { selectWsBearerProtocol } from '../bearerProtocol'; - -export const WS_PATH = '/api/v1/ws'; - -export interface RegisterWsV1Options { - readonly validateCredential?: CredentialValidator; - readonly registry: IConnectionRegistry; - readonly broadcaster: SessionEventBroadcaster; - readonly logger?: JournalLogger; - readonly maxBufferSize?: number; - readonly flushIntervalMs?: number; - readonly maxBatchSize?: number; - readonly highWaterMarkBytes?: number; - readonly heartbeatIntervalMs?: number; -} - -export function registerWsV1(core: Scope, opts: RegisterWsV1Options): WebSocketServer { - void core; - const wss = new WebSocketServer({ noServer: true, handleProtocols: selectWsBearerProtocol }); - const { registry, broadcaster } = opts; - - wss.on('connection', (socket, req) => { - const conn = new WsConnectionV1({ - socket, - broadcaster, - connectionRegistry: registry, - validateCredential: opts.validateCredential, - remoteAddress: req.socket.remoteAddress ?? null, - userAgent: req.headers['user-agent'] ?? null, - logger: opts.logger, - maxBufferSize: opts.maxBufferSize, - flushIntervalMs: opts.flushIntervalMs, - maxBatchSize: opts.maxBatchSize, - highWaterMarkBytes: opts.highWaterMarkBytes, - heartbeatIntervalMs: opts.heartbeatIntervalMs, - }); - socket.on('close', () => registry.remove(conn.id)); - }); - - return wss; -} diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts deleted file mode 100644 index 06af509aeba..00000000000 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ /dev/null @@ -1,1561 +0,0 @@ -import { rm } from 'node:fs/promises'; - -import type { - ApprovalResponse, - Event2, - IAgentScopeHandle, - IDisposable, - Interaction, - InteractionKind, - ISessionScopeHandle, - Scope, - SessionActivityState, - Workspace, -} from '@moonshot-ai/agent-core-v2'; -import { - IAgentLifecycleService, - IAgentLoopService, - IEventBus, - IEventService, - INTERACTION_TAG_AGENT_ID, - INTERACTION_TAG_SESSION_ID, - ISessionActivityView, - ISessionIndex, - ISessionManager, - MAIN_AGENT_ID, - getLiveSessionById, - interactions, - toDisposable, -} from '@moonshot-ai/agent-core-v2'; -import type { - ConfigWarningItem, - DiUnitChangedEvent, - ModelCatalogRefreshChange, - ModelCatalogRefreshFailure, - SessionCreatedEvent, - SessionMetaUpdatedEvent, - Event, -} from './events'; -import { isVolatileEventType } from './events'; -import type { SessionCursor } from '../../../protocol/ws-control'; -import { - configChangedEventSchema, - modelCatalogChangedEventSchema, -} from '../../../protocol/events-zod'; -import type { InFlightTurn, SnapshotSubagent } from '../../../protocol/rest-snapshot'; -import { - detachGrades, - filterOpsForGrade, - gradeFor, - needsResetOnTransition, - redactSnapshotForGrade, - type AgentTranscript, - type TranscriptGrade, - type TranscriptGradeSpec, - type TranscriptOperation, - type TranscriptOpsEvent, - type TranscriptResetEvent, - type TranscriptStore, -} from '@moonshot-ai/transcript'; - -import { toWireApproval } from '../../../routes/approvals'; -import { toWireQuestion } from '../../../protocol/question-wire'; -import { toWireWorkspace } from '../../../routes/workspaces'; -import { projectPromptContentParts } from '../../../services/messages/messageProjection'; -import { readLegacyStatus } from '../../../services/legacyStatus/legacyStatus'; -import { - legacyApprovalsOf, - LegacyActivityTracker, - phaseFromDomainEvent, -} from '../../../services/legacyStatus/legacyActivity'; -import type { TranscriptService } from '../../../services/transcript/transcriptService'; -import { InFlightTurnTracker } from './inFlightTurnTracker'; -import { SubagentRosterTracker } from './subagentRosterTracker'; -import { - type EventEnvelope, - type JournalLogger, - SessionEventJournal, - sessionJournalPath, -} from './sessionEventJournal'; - -export type ResyncReason = 'buffer_overflow' | 'session_recreated' | 'epoch_changed'; - -export interface BufferedSinceResult { - events: Array<{ seq: number; envelope: EventEnvelope }>; - resyncRequired: ResyncReason | false; - currentSeq: number; - epoch: string; -} - -export interface SessionSnapshotState { - seq: number; - epoch: string; - inFlightTurn: InFlightTurn | null; - subagents: SnapshotSubagent[]; -} - -export type BroadcastDelivery = 'subscription' | 'immediate'; - -export interface BroadcastTarget { - send(envelope: EventEnvelope, delivery?: BroadcastDelivery): void; -} - -export type AgentFilter = ReadonlySet | undefined; - -export interface TargetSubscription { - readonly agentFilter?: AgentFilter; - readonly transcriptGrades?: TranscriptGradeSpec; -} - -interface TranscriptStream { - readonly store: TranscriptStore; - readonly knownAgents: Set; -} - -interface SessionState { - readonly sessionId: string; - readonly journal: SessionEventJournal; - readonly tracker: InFlightTurnTracker; - readonly roster: SubagentRosterTracker; - deferredWork?: SessionActivityState; - readonly tail: Array<{ seq: number; envelope: EventEnvelope }>; - readonly targets: Map; - queue: Promise; - readonly agentDisposables: Map; - readonly lifecycleDisposables: IDisposable[]; - readonly knownInteractions: Map; - transcriptStream?: TranscriptStream; - readonly transcriptSeeded: Set; - readonly deferredTranscriptSeeds: Map< - BroadcastTarget, - { readonly spec: TranscriptGradeSpec; readonly transcriptSince?: Record } - >; -} - -export const DEFAULT_MAX_BUFFER_SIZE = 1000; -const GLOBAL_SESSION_ID = '__global__'; -const TRANSCRIPT_RESET_TAIL_TURNS = 0; - -async function disposeSessionState(state: SessionState): Promise { - for (const d of state.lifecycleDisposables) d.dispose(); - for (const d of state.agentDisposables.values()) d.dispose(); - await state.journal.close(); -} - -export class SessionEventBroadcaster { - private readonly sessions = new Map(); - private readonly globalTargets = new Set(); - private readonly diEventTargets = new Set(); - private readonly pendingStates = new Map>(); - private readonly activityTrackers = new Map(); - private readonly maxBufferSize: number; - private readonly coreEventSubscription: IDisposable; - private readonly deletionSubscription: IDisposable | undefined; - private closed = false; - - constructor( - private readonly opts: { - readonly eventsDir: string; - readonly core: Scope; - readonly logger?: JournalLogger; - readonly maxBufferSize?: number; - readonly transcriptService?: TranscriptService; - }, - ) { - this.maxBufferSize = opts.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE; - this.deletionSubscription = opts.core.accessor.get(ISessionManager).onWillDeleteSession?.( - (event) => { - event.waitUntil(this.purgeSession(event.sessionId)); - }, - ); - this.coreEventSubscription = opts.core.accessor - .get(IEventService) - .subscribe((event) => this.onCoreEvent(event)); - } - - addGlobalTarget(target: BroadcastTarget): void { - this.globalTargets.add(target); - } - - removeGlobalTarget(target: BroadcastTarget): void { - this.globalTargets.delete(target); - this.diEventTargets.delete(target); - } - - addDiEventTarget(target: BroadcastTarget): void { - this.diEventTargets.add(target); - } - - async subscribe( - sessionId: string, - target: BroadcastTarget, - filter?: AgentFilter, - transcriptGrades?: TranscriptGradeSpec, - opts?: { deferTranscriptReset?: boolean; transcriptSince?: Record }, - ): Promise { - const state = await this.ensureState(sessionId); - if (state === undefined) return false; - const prev = state.targets.get(target); - state.targets.set(target, { agentFilter: filter, transcriptGrades }); - if (transcriptGrades !== undefined) { - if (opts?.deferTranscriptReset === true) { - state.transcriptSeeded.delete(target); - state.deferredTranscriptSeeds.set(target, { - spec: transcriptGrades, - transcriptSince: opts.transcriptSince, - }); - } else { - state.deferredTranscriptSeeds.delete(target); - const gated = this.willSendTranscriptReset(state, transcriptGrades, prev); - if (gated) state.transcriptSeeded.delete(target); - await this.subscribeTranscript( - state, - target, - transcriptGrades, - prev?.transcriptGrades, - opts?.transcriptSince, - ); - if (state.targets.has(target)) state.transcriptSeeded.add(target); - } - } - return true; - } - - private willSendTranscriptReset( - state: SessionState, - spec: TranscriptGradeSpec, - prev: TargetSubscription | undefined, - ): boolean { - const service = this.opts.transcriptService; - if (service === undefined) return false; - const store = service.forSessionLive(state.sessionId); - if (store === undefined) return false; - for (const descriptor of store.agents()) { - const grade = gradeFor(spec, descriptor.agentId); - if (grade === 'off') continue; - if (needsResetOnTransition(gradeFor(prev?.transcriptGrades, descriptor.agentId), grade)) { - return true; - } - } - return false; - } - - async flushTranscriptSeed(sessionId: string, target: BroadcastTarget): Promise { - const state = this.sessions.get(sessionId); - if (state === undefined) return; - const deferred = state.deferredTranscriptSeeds.get(target); - if (deferred === undefined) return; - state.deferredTranscriptSeeds.delete(target); - await this.subscribeTranscript(state, target, deferred.spec, undefined, deferred.transcriptSince); - if (state.targets.has(target)) state.transcriptSeeded.add(target); - } - - unsubscribe(sessionId: string, target: BroadcastTarget): void { - const state = this.sessions.get(sessionId); - if (state === undefined) return; - state.targets.delete(target); - state.transcriptSeeded.delete(target); - state.deferredTranscriptSeeds.delete(target); - } - - unsubscribeTranscript( - sessionId: string, - target: BroadcastTarget, - agentIds?: readonly string[], - ): void { - const state = this.sessions.get(sessionId); - if (state === undefined) return; - const sub = state.targets.get(target); - if (sub === undefined) return; - const next = - agentIds === undefined ? undefined : detachGrades(sub.transcriptGrades, agentIds); - if (next === undefined) { - state.targets.set(target, { agentFilter: sub.agentFilter, transcriptGrades: undefined }); - state.transcriptSeeded.delete(target); - state.deferredTranscriptSeeds.delete(target); - } else { - state.targets.set(target, { agentFilter: sub.agentFilter, transcriptGrades: next }); - } - } - - private async subscribeTranscript( - state: SessionState, - target: BroadcastTarget, - spec: TranscriptGradeSpec, - prev: TranscriptGradeSpec | undefined, - transcriptSince?: Record, - ): Promise { - const service = this.opts.transcriptService; - if (service === undefined) return; - const store = service.forSessionLive(state.sessionId); - if (store === undefined) return; - await service.whenReady(state.sessionId); - const backfill = new Set( - Object.keys(spec).filter((agentId) => agentId !== '*' && gradeFor(spec, agentId) !== 'off'), - ); - for (const descriptor of store.agents()) { - if (gradeFor(spec, descriptor.agentId) !== 'off') backfill.add(descriptor.agentId); - } - await Promise.all( - [...backfill].map((agentId) => service.ensureAgentHistory(state.sessionId, agentId)), - ); - const current = state.targets.get(target); - if (current?.transcriptGrades === undefined) return; - const currentSpec = current.transcriptGrades; - this.ensureTranscriptStream(state, store); - for (const descriptor of store.agents()) { - const grade = gradeFor(currentSpec, descriptor.agentId); - if (grade === 'off') continue; - const transcript = store.getAgent(descriptor.agentId); - if (transcript === undefined) continue; - const since = transcriptSince?.[descriptor.agentId] ?? transcriptSince?.['*']; - if (since !== undefined) { - const catchup = service.getOpsSince(state.sessionId, descriptor.agentId, since); - if (catchup !== undefined && catchup.complete) { - this.replayTranscriptOps(state, target, descriptor.agentId, grade, catchup.batches); - continue; - } - } - if (!needsResetOnTransition(gradeFor(prev, descriptor.agentId), grade)) { - continue; - } - this.sendTranscriptReset(state, target, transcript, grade); - } - } - - private replayTranscriptOps( - state: SessionState, - target: BroadcastTarget, - agentId: string, - grade: TranscriptGrade, - batches: readonly { seq: number; ops: readonly TranscriptOperation[] }[], - ): void { - for (const batch of batches) { - const filtered = filterOpsForGrade(grade, batch.ops); - if (filtered.length === 0) continue; - try { - target.send( - this.buildTranscriptEnvelope(state, 'transcript.ops', { - agent_id: agentId, - ops: filtered, - seq: batch.seq, - }), - ); - } catch { - } - } - } - - private ensureTranscriptStream(state: SessionState, store: TranscriptStore): void { - if (state.transcriptStream?.store === store) return; - const service = this.opts.transcriptService; - if (service === undefined) return; - const stream: TranscriptStream = { - store, - knownAgents: new Set(store.agents().map((d) => d.agentId)), - }; - state.transcriptStream = stream; - - const opsDisposable = service.onSessionOps(state.sessionId, ({ agentId, ops }, seq) => { - for (const [target, sub] of state.targets) { - if (!state.transcriptSeeded.has(target)) continue; - const grade = gradeFor(sub.transcriptGrades, agentId); - const filtered = filterOpsForGrade(grade, ops); - if (filtered.length === 0) continue; - try { - target.send( - this.buildTranscriptEnvelope(state, 'transcript.ops', { - agent_id: agentId, - ops: filtered, - seq, - }), - ); - } catch { - } - } - }); - if (opsDisposable !== undefined) state.lifecycleDisposables.push(opsDisposable); - - state.lifecycleDisposables.push( - store.onRosterChange((agents) => { - for (const descriptor of agents) { - if (stream.knownAgents.has(descriptor.agentId)) continue; - stream.knownAgents.add(descriptor.agentId); - const transcript = store.getAgent(descriptor.agentId); - if (transcript === undefined) continue; - for (const [target, sub] of state.targets) { - if (!state.transcriptSeeded.has(target)) continue; - const grade = gradeFor(sub.transcriptGrades, descriptor.agentId); - if (grade === 'off') continue; - try { - this.sendTranscriptReset(state, target, transcript, grade); - } catch { - } - } - } - }), - ); - } - - private sendTranscriptReset( - state: SessionState, - target: BroadcastTarget, - transcript: AgentTranscript, - grade: TranscriptGrade, - ): void { - const snapshot = redactSnapshotForGrade( - grade, - transcript.snapshot({ tailTurns: TRANSCRIPT_RESET_TAIL_TURNS }), - ); - target.send( - this.buildTranscriptEnvelope(state, 'transcript.reset', { - agent_id: transcript.agentId, - snapshot, - has_more_older: snapshot.hasMoreOlder ?? false, - seq: this.opts.transcriptService?.getSeqWatermark(state.sessionId, transcript.agentId), - }), - ); - } - - private buildTranscriptEnvelope( - state: SessionState, - type: 'transcript.reset' | 'transcript.ops', - payload: Omit | Omit, - ): EventEnvelope { - return { - type, - seq: state.journal.seq, - epoch: state.journal.epoch, - volatile: true, - session_id: state.sessionId, - timestamp: new Date().toISOString(), - payload: { type, ...payload }, - }; - } - - async getBufferedSince( - sessionId: string, - cursor: SessionCursor, - filter?: AgentFilter, - transcriptGrades?: TranscriptGradeSpec, - ): Promise { - const state = await this.ensureState(sessionId); - if (state === undefined) { - return { events: [], resyncRequired: 'session_recreated', currentSeq: 0, epoch: '' }; - } - await state.queue; - const { journal, tail } = state; - const currentSeq = journal.seq; - const { epoch } = journal; - - if (cursor.epoch !== undefined && cursor.epoch !== epoch) { - return { events: [], resyncRequired: 'epoch_changed', currentSeq, epoch }; - } - if (cursor.seq > currentSeq) { - return { events: [], resyncRequired: 'epoch_changed', currentSeq, epoch }; - } - if (cursor.seq === currentSeq) { - return { events: [], resyncRequired: false, currentSeq, epoch }; - } - if (currentSeq - cursor.seq > this.maxBufferSize) { - return { events: [], resyncRequired: 'buffer_overflow', currentSeq, epoch }; - } - - const applyFilter = ( - entries: Array<{ seq: number; envelope: EventEnvelope }>, - ): Array<{ seq: number; envelope: EventEnvelope }> => - filter === undefined && transcriptGrades === undefined - ? entries - : entries.filter( - ({ envelope }) => - matchesAgentFilter(envelope, filter) && - !suppressedByTranscript(envelope, transcriptGrades), - ); - - const tailStart = tail[0]?.seq; - if (tailStart !== undefined && tailStart <= cursor.seq + 1) { - const events = applyFilter(tail.filter((e) => e.seq > cursor.seq)); - return { events, resyncRequired: false, currentSeq, epoch }; - } - const fromDisk = await journal.readSince(cursor.seq, this.maxBufferSize); - return { events: applyFilter(fromDisk), resyncRequired: false, currentSeq, epoch }; - } - - async getCursor(sessionId: string): Promise<{ seq: number; epoch: string }> { - const state = await this.ensureState(sessionId); - if (state === undefined) { - const cold = await this.readColdWatermark(sessionId); - return cold ?? { seq: 0, epoch: '' }; - } - await state.queue; - return { seq: state.journal.seq, epoch: state.journal.epoch }; - } - - async getSnapshotState(sessionId: string): Promise { - const state = await this.ensureState(sessionId); - if (state === undefined) { - const cold = await this.readColdWatermark(sessionId); - return cold !== undefined - ? { ...cold, inFlightTurn: null, subagents: [] } - : { seq: 0, epoch: '', inFlightTurn: null, subagents: [] }; - } - await state.queue; - return { - seq: state.journal.seq, - epoch: state.journal.epoch, - inFlightTurn: state.tracker.get(sessionId), - subagents: state.roster.get(sessionId), - }; - } - - private async readColdWatermark( - sessionId: string, - ): Promise<{ seq: number; epoch: string } | undefined> { - const summary = await this.opts.core.accessor.get(ISessionIndex).get(sessionId); - if (summary === undefined) return undefined; - const journal = await SessionEventJournal.open( - sessionJournalPath(this.opts.eventsDir, sessionId), - this.opts.logger, - ); - const watermark = { seq: journal.seq, epoch: journal.epoch }; - await journal.close(); - return watermark; - } - - async close(): Promise { - if (this.closed) return; - this.closed = true; - this.coreEventSubscription.dispose(); - this.deletionSubscription?.dispose(); - await Promise.all( - [...this.pendingStates.values()].map((pending) => pending.catch(() => undefined)), - ); - for (const [sessionId, state] of this.sessions) { - await disposeSessionState(state); - this.dropActivityTrackers(sessionId); - this.opts.transcriptService?.dropSession(sessionId); - } - this.sessions.clear(); - } - - private dropActivityTrackers(sessionId: string): void { - for (const key of this.activityTrackers.keys()) { - if (key.startsWith(`${sessionId}:`)) this.activityTrackers.delete(key); - } - } - - private async purgeSession(sessionId: string): Promise { - await this.pendingStates.get(sessionId); - const state = this.sessions.get(sessionId); - if (state !== undefined) { - this.sessions.delete(sessionId); - state.targets.clear(); - await disposeSessionState(state); - this.dropActivityTrackers(sessionId); - } - this.opts.transcriptService?.dropSession(sessionId); - await rm(sessionJournalPath(this.opts.eventsDir, sessionId), { force: true }); - } - - private ensureState(sessionId: string): Promise { - if (this.closed) return Promise.resolve(undefined); - const existing = this.sessions.get(sessionId); - if (existing !== undefined) return Promise.resolve(existing); - let pending = this.pendingStates.get(sessionId); - if (pending === undefined) { - pending = this.createSessionState(sessionId).finally(() => { - if (this.pendingStates.get(sessionId) === pending) { - this.pendingStates.delete(sessionId); - } - }); - this.pendingStates.set(sessionId, pending); - } - return pending; - } - - private async createSessionState(sessionId: string): Promise { - if (this.closed) return undefined; - - const session = getLiveSessionById(this.opts.core.accessor, sessionId); - if (session === undefined) return undefined; - - const journal = await SessionEventJournal.open( - sessionJournalPath(this.opts.eventsDir, sessionId), - this.opts.logger, - ); - if (this.closed || getLiveSessionById(this.opts.core.accessor, sessionId) !== session) { - await journal.close(); - return undefined; - } - const state: SessionState = { - sessionId, - journal, - tracker: new InFlightTurnTracker(), - roster: new SubagentRosterTracker(), - tail: [], - targets: new Map(), - queue: Promise.resolve(), - agentDisposables: new Map(), - lifecycleDisposables: [], - knownInteractions: new Map(), - transcriptSeeded: new Set(), - deferredTranscriptSeeds: new Map(), - }; - this.sessions.set(sessionId, state); - try { - this.attachWorkView(session, state); - this.attachAgents(sessionId, session, state); - this.attachInteractions(sessionId, state); - } catch (error) { - this.sessions.delete(sessionId); - await disposeSessionState(state); - this.dropActivityTrackers(sessionId); - if (error instanceof Error && error.message === 'InstantiationService has been disposed') return undefined; - throw error; - } - return state; - } - - private ensureGlobalState(): Promise { - if (this.closed) return Promise.resolve(undefined); - const existing = this.sessions.get(GLOBAL_SESSION_ID); - if (existing !== undefined) return Promise.resolve(existing); - let pending = this.pendingStates.get(GLOBAL_SESSION_ID); - if (pending === undefined) { - pending = this.createGlobalState().finally(() => { - if (this.pendingStates.get(GLOBAL_SESSION_ID) === pending) { - this.pendingStates.delete(GLOBAL_SESSION_ID); - } - }); - this.pendingStates.set(GLOBAL_SESSION_ID, pending); - } - return pending; - } - - private async createGlobalState(): Promise { - const journal = await SessionEventJournal.open( - sessionJournalPath(this.opts.eventsDir, GLOBAL_SESSION_ID), - this.opts.logger, - ); - if (this.closed) { - await journal.close(); - return undefined; - } - const state: SessionState = { - sessionId: GLOBAL_SESSION_ID, - journal, - tracker: new InFlightTurnTracker(), - roster: new SubagentRosterTracker(), - tail: [], - targets: new Map(), - queue: Promise.resolve(), - agentDisposables: new Map(), - lifecycleDisposables: [], - knownInteractions: new Map(), - transcriptSeeded: new Set(), - deferredTranscriptSeeds: new Map(), - }; - this.sessions.set(GLOBAL_SESSION_ID, state); - return state; - } - - private onCoreEvent(event: Event2): void { - const corePayload = (event as { readonly payload?: unknown }).payload; - if (event.type === 'event.session.created') { - const payload = sessionCreatedPayload(corePayload); - if (payload === undefined) return; - void this.dispatchSessionEvent(payload.sessionId, { - type: 'event.session.created', - session: payload.session, - agentId: 'main', - sessionId: payload.sessionId, - } as Event).catch((error: unknown) => - this.logDispatchError(payload.sessionId, 'event.session.created', error), - ); - return; - } - if (event.type === 'event.session.archived') { - const payload = sessionArchivedPayload(corePayload); - if (payload === undefined) return; - void this.dispatchGlobal({ - type: 'event.session.archived', - workspace_id: payload.workspaceId, - agentId: 'main', - sessionId: payload.sessionId, - } as Event).catch((error: unknown) => - this.logDispatchError(GLOBAL_SESSION_ID, 'event.session.archived', error), - ); - return; - } - if (event.type === 'event.session.deleted') { - const payload = sessionDeletedPayload(corePayload); - if (payload === undefined) return; - void this.dispatchGlobal({ - type: 'event.session.deleted', - workspace_id: payload.workspaceId, - agentId: 'main', - sessionId: payload.sessionId, - } as Event).catch((error: unknown) => - this.logDispatchError(GLOBAL_SESSION_ID, 'event.session.deleted', error), - ); - return; - } - if (event.type === 'event.workspace.created' || event.type === 'event.workspace.updated') { - const workspace = workspaceLifecyclePayload(corePayload); - if (workspace === undefined) return; - const type = event.type; - void (async () => { - const wire = await toWireWorkspace(this.opts.core, workspace); - await this.dispatchGlobal({ - type, - workspace: wire, - agentId: 'main', - sessionId: GLOBAL_SESSION_ID, - } as Event); - })().catch((error: unknown) => this.logDispatchError(GLOBAL_SESSION_ID, type, error)); - return; - } - if (event.type === 'event.workspace.deleted') { - const payload = workspaceDeletedPayload(corePayload); - if (payload === undefined) return; - void this.dispatchGlobal({ - type: 'event.workspace.deleted', - workspace_id: payload.workspaceId, - root: payload.root, - agentId: 'main', - sessionId: GLOBAL_SESSION_ID, - } as Event).catch((error: unknown) => - this.logDispatchError(GLOBAL_SESSION_ID, 'event.workspace.deleted', error), - ); - return; - } - if (event.type === 'session.meta.updated') { - const payload = sessionMetaUpdatedPayload(corePayload); - if (payload === undefined) return; - const sessionId = sessionMetaUpdatedSessionId(corePayload); - if (sessionId === undefined) return; - void this.dispatchSessionEvent(sessionId, { - type: 'session.meta.updated', - ...payload, - agentId: 'main', - sessionId, - } as Event).catch((error: unknown) => - this.logDispatchError(sessionId, 'session.meta.updated', error), - ); - return; - } - if (event.type === 'event.plugin.changed') { - void this.dispatchGlobal({ - type: 'event.plugin.changed', - agentId: 'main', - sessionId: GLOBAL_SESSION_ID, - } as Event).catch((error: unknown) => - this.logDispatchError(GLOBAL_SESSION_ID, 'event.plugin.changed', error), - ); - return; - } - if (event.type === 'event.capability.changed') { - const payload = capabilityChangedPayload(corePayload); - if (payload === undefined) return; - void this.dispatchGlobal({ - type: 'event.capability.changed', - ...payload, - agentId: 'main', - sessionId: GLOBAL_SESSION_ID, - } as Event).catch((error: unknown) => - this.logDispatchError(GLOBAL_SESSION_ID, 'event.capability.changed', error), - ); - return; - } - if (event.type === 'event.config.warning') { - const payload = configWarningPayload(corePayload); - if (payload === undefined) return; - void this.dispatchGlobal({ - type: 'event.config.warning', - warnings: payload.warnings, - agentId: 'main', - sessionId: GLOBAL_SESSION_ID, - } as Event).catch((error: unknown) => - this.logDispatchError(GLOBAL_SESSION_ID, 'event.config.warning', error), - ); - return; - } - if (event.type === 'event.config.changed') { - const payload = configChangedPayload(corePayload); - if (payload === undefined) return; - void this.dispatchGlobal({ - type: 'event.config.changed', - changedFields: payload.changedFields, - config: payload.config, - agentId: 'main', - sessionId: GLOBAL_SESSION_ID, - } as Event).catch((error: unknown) => - this.logDispatchError(GLOBAL_SESSION_ID, 'event.config.changed', error), - ); - return; - } - if (event.type === 'event.model_catalog.changed') { - const payload = modelCatalogChangedPayload(corePayload); - if (payload === undefined) return; - void this.dispatchGlobal({ - type: 'event.model_catalog.changed', - changed: payload.changed, - unchanged: payload.unchanged, - failed: payload.failed, - agentId: 'main', - sessionId: GLOBAL_SESSION_ID, - } as Event).catch((error: unknown) => - this.logDispatchError(GLOBAL_SESSION_ID, 'event.model_catalog.changed', error), - ); - return; - } - if (event.type === 'event.di.unit_changed') { - const payload = diUnitChangedPayload(corePayload); - if (payload === undefined) return; - void this.dispatchGlobal({ - type: 'event.di.unit_changed', - ...payload, - agentId: 'main', - sessionId: GLOBAL_SESSION_ID, - } as Event).catch((error: unknown) => - this.logDispatchError(GLOBAL_SESSION_ID, 'event.di.unit_changed', error), - ); - return; - } - } - - private async dispatchGlobal(event: Event): Promise { - const state = await this.ensureGlobalState(); - if (state === undefined) return; - state.queue = state.queue - .then(() => this.dispatch(state, event, isVolatileEventType(event.type))) - .catch((error: unknown) => this.logDispatchDropped(state.sessionId, event.type, error)); - } - - private async dispatchSessionEvent(sessionId: string, event: Event): Promise { - let state: SessionState | undefined; - try { - state = await this.ensureState(sessionId); - } catch (error) { - if (error instanceof Error && error.message === 'InstantiationService has been disposed') { - return; - } - throw error; - } - if (state === undefined) return; - state.queue = state.queue - .then(() => this.dispatch(state, event, isVolatileEventType(event.type))) - .catch((error: unknown) => this.logDispatchDropped(state.sessionId, event.type, error)); - } - - private attachWorkView(session: ISessionScopeHandle, state: SessionState): void { - const workView = session.accessor.get(ISessionActivityView); - workView.state(); - state.lifecycleDisposables.push( - workView.onDidChange(({ state: work, cause }) => { - if (cause === 'turn_ended') { - state.deferredWork = work; - queueMicrotask(() => { - if (this.sessions.get(state.sessionId) !== state) return; - this.flushDeferredWork(state); - }); - return; - } - this.flushDeferredWork(state); - this.enqueueWorkChanged(state, work); - }), - ); - } - - private flushDeferredWork(state: SessionState): void { - const deferred = state.deferredWork; - if (deferred === undefined) return; - state.deferredWork = undefined; - this.enqueueWorkChanged(state, deferred); - } - - private attachAgents(sessionId: string, session: ISessionScopeHandle, state: SessionState): void { - const agents = session.accessor.get(IAgentLifecycleService); - const subscribeAgent = (handle: IAgentScopeHandle): void => { - if (state.agentDisposables.has(handle.id)) return; - state.agentDisposables.set(handle.id, this.attachAgent(sessionId, handle)); - }; - for (const agent of agents.list()) { - const handle = agents.handleOf(agent.agentId); - if (handle !== undefined) subscribeAgent(handle); - } - state.lifecycleDisposables.push( - agents.onDidCreate((context) => { - const handle = agents.handleOf(context.agentId); - if (handle !== undefined) subscribeAgent(handle); - this.enqueueDurable(state, { - type: 'agent.created', - agentId: context.agentId, - sessionId, - }); - }), - agents.onDidClose((context) => { - const agentId = context.agentId; - const d = state.agentDisposables.get(agentId); - if (d !== undefined) { - d.dispose(); - state.agentDisposables.delete(agentId); - this.activityTrackers.delete(`${sessionId}:${agentId}`); - this.enqueueDurable(state, { - type: 'agent.disposed', - agentId, - sessionId, - }); - } - }), - ); - } - - private attachAgent(sessionId: string, handle: IAgentScopeHandle): IDisposable { - const eventBus = handle.accessor.get(IEventBus); - this.activityTrackers.set( - `${sessionId}:${handle.id}`, - new LegacyActivityTracker( - () => handle.accessor.get(IAgentLoopService).activitySnapshot(), - () => legacyApprovalsOf(handle), - ), - ); - let lastLegacyStatus: string | undefined; - const emitLegacyStatus = (): void => { - const snapshot = readLegacyStatus(handle); - if (snapshot === undefined) return; - const key = JSON.stringify(snapshot); - if (key === lastLegacyStatus) return; - lastLegacyStatus = key; - this.onAgentEvent(sessionId, MAIN_AGENT_ID, { - type: 'agent.status.updated', - ...snapshot, - } as unknown as Event2); - }; - const disposables: IDisposable[] = [ - eventBus.subscribe((event) => { - let projected: Event2 = event; - if (event.type === 'agent.status.updated') { - const snapshot = readLegacyStatus(handle); - if (snapshot !== undefined) { - lastLegacyStatus = JSON.stringify(snapshot); - projected = Object.assign({}, event, snapshot) as unknown as Event2; - } - } - if (handle.id === MAIN_AGENT_ID && event.type === 'context.spliced') { - emitLegacyStatus(); - } - this.onAgentEvent(sessionId, handle.id, projected); - }), - ]; - - return { dispose: () => disposables.forEach((disposable) => disposable.dispose()) }; - } - - private onAgentEvent(sessionId: string, agentId: string, event: Event2): void { - const state = this.sessions.get(sessionId); - if (state === undefined) return; - - if (event.type === 'prompt.accepted') return; - - if ( - event.type === 'agent.status.updated' && - (event as { phase?: unknown }).phase !== undefined - ) { - return; - } - - let wireEvent: Event; - if (event.type === 'turn.started') { - const { promptAttachments: _internal, ...wireFields } = event as typeof event & { - promptAttachments?: unknown; - }; - wireEvent = Object.assign({}, wireFields, { agentId, sessionId }) as unknown as Event; - } else if (event.type === 'prompt.steered' || event.type === 'prompt.queued' || event.type === 'prompt.submitted') { - const content = (event as unknown as { content: Parameters[0] }).content; - wireEvent = Object.assign({}, event, { - content: projectPromptContentParts(content), - agentId, - sessionId, - }) as unknown as Event; - } else { - wireEvent = Object.assign({}, event, { agentId, sessionId }) as unknown as Event; - } - const volatile = isVolatileSignal(event.type); - state.queue = state.queue - .then(() => this.dispatch(state, wireEvent, volatile)) - .catch((error: unknown) => this.logDispatchDropped(state.sessionId, wireEvent.type, error)); - const legacy = legacyTaskEvent(event, agentId, sessionId); - if (legacy !== undefined) { - state.queue = state.queue - .then(() => this.dispatch(state, legacy, volatile)) - .catch((error: unknown) => this.logDispatchDropped(state.sessionId, legacy.type, error)); - } - const tracker = this.activityTrackers.get(`${sessionId}:${agentId}`); - if (tracker !== undefined) { - const phase = phaseFromDomainEvent(tracker, event); - if (phase !== undefined) { - const phaseEvent = { - type: 'agent.status.updated', - phase, - agentId, - sessionId, - } as unknown as Event; - state.queue = state.queue - .then(() => this.dispatch(state, phaseEvent, true)) - .catch((error: unknown) => this.logDispatchDropped(state.sessionId, phaseEvent.type, error)); - } - } - } - - private attachInteractions( - sessionId: string, - state: SessionState, - ): void { - const pendingOfSession = (): readonly Interaction[] => - interactions.findAll({ - resolved: false, - tags: { [INTERACTION_TAG_SESSION_ID]: sessionId }, - }); - for (const i of pendingOfSession()) { - state.knownInteractions.set(i.id, { kind: i.kind, agentId: interactionAgentId(i) }); - } - state.lifecycleDisposables.push( - toDisposable( - interactions.onDidChangePending(() => { - for (const i of pendingOfSession()) { - if (state.knownInteractions.has(i.id)) continue; - state.knownInteractions.set(i.id, { - kind: i.kind, - agentId: interactionAgentId(i), - }); - const event = interactionRequestedEvent(i, sessionId); - if (event !== undefined) { - this.enqueueDurable(state, event); - } - } - }), - ), - toDisposable( - interactions.onDidResolve(({ id, response }) => { - const known = state.knownInteractions.get(id); - if (known === undefined) return; - state.knownInteractions.delete(id); - const event = interactionResolvedEvent(known.kind, id, response, sessionId, known.agentId); - if (event !== undefined) { - this.enqueueDurable(state, event); - } - }), - ), - ); - } - - private enqueueDurable(state: SessionState, event: Event): void { - state.queue = state.queue - .then(() => this.dispatch(state, event, false)) - .catch((error: unknown) => this.logDispatchDropped(state.sessionId, event.type, error)); - } - - private enqueueWorkChanged(state: SessionState, work: SessionActivityState): void { - state.queue = state.queue - .then(() => - this.dispatch( - state, - { - type: 'event.session.work_changed', - busy: work.busy, - main_turn_active: work.mainTurnActive, - pending_interaction: work.pendingInteraction, - last_turn_reason: work.lastTurnReason, - agentId: 'main', - sessionId: state.sessionId, - } as Event, - false, - ), - ) - .catch((error: unknown) => - this.logDispatchDropped(state.sessionId, 'event.session.work_changed', error), - ); - } - - private logDispatchError(sessionId: string, eventType: string, error: unknown): void { - const logger = this.opts.logger; - if (logger === undefined) return; - if (logger.error !== undefined) { - logger.error({ sessionId, eventType, err: error }, 'session event dispatch failed'); - } else { - logger.warn({ sessionId, eventType, err: error }, 'session event dispatch failed'); - } - } - - private logDispatchDropped(sessionId: string, eventType: string, error: unknown): void { - this.opts.logger?.warn( - { sessionId, eventType, err: error }, - 'session event dispatch failed; event dropped', - ); - } - - private async dispatch(state: SessionState, event: Event, volatile: boolean): Promise { - const { journal, tracker, roster, tail, targets, sessionId } = state; - const annotation = tracker.apply(sessionId, event); - roster.apply(sessionId, event); - - let envelope: EventEnvelope; - if (volatile) { - envelope = this.buildEnvelope(journal.seq, sessionId, event, { - epoch: journal.epoch, - volatile: true, - ...(annotation.offset !== undefined ? { offset: annotation.offset } : {}), - }); - } else { - const seq = journal.nextSeq(); - envelope = this.buildEnvelope(seq, sessionId, event, { epoch: journal.epoch }); - journal.append(seq, envelope); - tail.push({ seq, envelope }); - while (tail.length > this.maxBufferSize) tail.shift(); - } - - if (isGlobalEvent(event.type)) { - const recipients = new Set(this.globalTargets); - for (const target of this.allTargets()) recipients.add(target); - const diGated = event.type.startsWith('event.di.'); - for (const target of recipients) { - if (diGated && !this.diEventTargets.has(target)) continue; - try { - target.send(envelope, 'immediate'); - } catch { - } - } - } else { - for (const [target, sub] of targets) { - if (!matchesAgentFilter(envelope, sub.agentFilter)) continue; - if (suppressedByTranscript(envelope, sub.transcriptGrades)) continue; - try { - target.send(envelope); - } catch { - } - } - } - } - - private buildEnvelope( - seq: number, - sessionId: string, - event: Event, - extras: { epoch?: string; volatile?: boolean; offset?: number }, - ): EventEnvelope { - return { - type: event.type, - seq, - session_id: sessionId, - timestamp: - event.time !== undefined - ? new Date(event.time).toISOString() - : new Date().toISOString(), - payload: event, - ...extras, - }; - } - - private *allTargets(): Iterable { - for (const state of this.sessions.values()) { - for (const target of state.targets.keys()) yield target; - } - } -} - -const VOLATILE_SIGNAL_TYPES = [ - 'assistant.delta', - 'thinking.delta', - 'tool.call.delta', - 'tool.progress', - 'shell.output', - 'shell.started', - 'shell.completed', - 'agent.status.updated', -] as const; - -const volatileSignalTypeSet: ReadonlySet = new Set(VOLATILE_SIGNAL_TYPES); - -function isVolatileSignal(type: string): boolean { - return volatileSignalTypeSet.has(type); -} - -function legacyTaskEvent(event: Event2, agentId: string, sessionId: string): Event | undefined { - if (event.type !== 'task.started' && event.type !== 'task.terminated') return undefined; - const legacyType = - event.type === 'task.started' ? 'background.task.started' : 'background.task.terminated'; - return Object.assign({}, event, { type: legacyType, agentId, sessionId }) as unknown as Event; -} - -function isGlobalEvent(type: string): boolean { - return ( - type === 'session.meta.updated' || - type.startsWith('event.session.') || - type.startsWith('event.workspace.') || - type.startsWith('event.config.') || - type.startsWith('event.model_catalog.') || - type.startsWith('event.plugin.') || - type.startsWith('event.capability.') || - type.startsWith('event.di.') - ); -} - -function isAgentLifecycleEvent(type: string): boolean { - return type === 'agent.created' || type === 'agent.disposed'; -} - -function matchesAgentFilter(envelope: EventEnvelope, filter: AgentFilter): boolean { - if (filter === undefined) return true; - if (isGlobalEvent(envelope.type)) return true; - if (isAgentLifecycleEvent(envelope.type)) return true; - const payload = envelope.payload; - const agentId = - typeof payload === 'object' && payload !== null - ? (payload as { agentId?: unknown }).agentId - : undefined; - if (typeof agentId !== 'string') return true; - return filter.has(agentId); -} - -const TRANSCRIPT_PROJECTED_EVENT_TYPES: ReadonlySet = new Set([ - 'turn.started', - 'turn.ended', - 'turn.step.started', - 'turn.step.completed', - 'turn.step.interrupted', - 'turn.step.retrying', - 'assistant.delta', - 'thinking.delta', - 'tool.call.delta', - 'tool.call.started', - 'tool.progress', - 'tool.result', - 'shell.started', - 'shell.output', - 'shell.completed', - 'task.started', - 'task.terminated', - 'background.task.started', - 'background.task.terminated', - 'task.notified', - 'subagent.spawned', - 'subagent.started', - 'subagent.completed', - 'subagent.failed', - 'subagent.suspended', - 'compaction.started', - 'compaction.blocked', - 'compaction.cancelled', - 'compaction.completed', - 'skill.activated', - 'plugin_command.activated', - 'cron.fired', - 'error', - 'warning', - 'goal.updated', - 'plan.revision', - 'context.spliced', - 'agent.status.updated', - 'hook.result', - 'prompt.submitted', - 'prompt.started', - 'prompt.completed', - 'prompt.aborted', - 'prompt.steered', - 'turn.steer', - 'event.question.requested', - 'event.question.dismissed', - 'event.question.answered', - 'event.approval.requested', - 'event.approval.resolved', -]); - -function suppressedByTranscript( - envelope: EventEnvelope, - spec: TranscriptGradeSpec | undefined, -): boolean { - if (spec === undefined) return false; - if (isGlobalEvent(envelope.type)) return false; - if (isAgentLifecycleEvent(envelope.type)) return false; - const payload = envelope.payload; - const agentId = - typeof payload === 'object' && payload !== null - ? (payload as { agentId?: unknown }).agentId - : undefined; - if (typeof agentId !== 'string') return false; - if (gradeFor(spec, agentId) === 'off') return false; - return TRANSCRIPT_PROJECTED_EVENT_TYPES.has(envelope.type); -} - -function interactionAgentId(interaction: Interaction): string { - const tag = interaction.tags[INTERACTION_TAG_AGENT_ID]; - return typeof tag === 'string' ? tag : MAIN_AGENT_ID; -} - -function interactionRequestedEvent(interaction: Interaction, sessionId: string): Event | undefined { - const agentId = interactionAgentId(interaction); - switch (interaction.kind) { - case 'question': - return { - type: 'event.question.requested', - agentId, - sessionId, - ...toWireQuestion(interaction, sessionId), - } as unknown as Event; - case 'approval': - return { - type: 'event.approval.requested', - agentId, - sessionId, - ...toWireApproval(interaction, sessionId), - } as unknown as Event; - default: - return undefined; - } -} - -function interactionResolvedEvent( - kind: InteractionKind, - id: string, - response: unknown, - sessionId: string, - agentId: string, -): Event | undefined { - const resolvedAt = new Date().toISOString(); - switch (kind) { - case 'question': { - if (response === null) { - return { - type: 'event.question.dismissed', - agentId, - sessionId, - question_id: id, - dismissed_at: resolvedAt, - } as unknown as Event; - } - const answers = (response as { answers?: unknown }).answers ?? response; - return { - type: 'event.question.answered', - agentId, - sessionId, - question_id: id, - answers, - resolved_at: resolvedAt, - } as unknown as Event; - } - case 'approval': { - const r = response as Partial; - return { - type: 'event.approval.resolved', - agentId, - sessionId, - approval_id: id, - decision: r.decision, - scope: r.scope, - feedback: r.feedback, - selected_label: r.selectedLabel, - resolved_at: resolvedAt, - } as unknown as Event; - } - default: - return undefined; - } -} - -function sessionMetaUpdatedPayload( - payload: unknown, -): Pick | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const candidate = payload as Partial; - const title = typeof candidate.title === 'string' ? candidate.title : undefined; - const patch = - typeof candidate.patch === 'object' && - candidate.patch !== null && - !Array.isArray(candidate.patch) - ? candidate.patch - : undefined; - if (title === undefined && patch === undefined) return undefined; - return { title, patch }; -} - -function sessionMetaUpdatedSessionId(payload: unknown): string | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const sessionId = (payload as { sessionId?: unknown }).sessionId; - return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined; -} - -const DI_UNIT_STATES: ReadonlySet = new Set([ - 'Pending', - 'Activating', - 'Active', - 'Unloading', - 'Failed', -]); - -function diUnitChangedPayload( - payload: unknown, -): Pick | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const candidate = payload as Partial; - if (typeof candidate.scope !== 'string' || candidate.scope.length === 0) return undefined; - if (typeof candidate.token !== 'string' || candidate.token.length === 0) return undefined; - if (typeof candidate.state !== 'string' || !DI_UNIT_STATES.has(candidate.state)) { - return undefined; - } - return { - scope: candidate.scope, - token: candidate.token, - state: candidate.state as DiUnitChangedEvent['state'], - error: typeof candidate.error === 'string' ? candidate.error : undefined, - }; -} - -function sessionCreatedPayload( - payload: unknown, -): { sessionId: string; session: SessionCreatedEvent['session'] } | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const candidate = payload as { sessionId?: unknown; session?: unknown }; - const sessionId = - typeof candidate.sessionId === 'string' && candidate.sessionId.length > 0 - ? candidate.sessionId - : undefined; - const session = - typeof candidate.session === 'object' && - candidate.session !== null && - !Array.isArray(candidate.session) - ? (candidate.session as SessionCreatedEvent['session']) - : undefined; - if (sessionId === undefined || session === undefined) return undefined; - return { sessionId, session }; -} - -function sessionArchivedPayload( - payload: unknown, -): { sessionId: string; workspaceId: string } | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const candidate = payload as { sessionId?: unknown; workspaceId?: unknown }; - if (typeof candidate.sessionId !== 'string' || candidate.sessionId.length === 0) { - return undefined; - } - if (typeof candidate.workspaceId !== 'string' || candidate.workspaceId.length === 0) { - return undefined; - } - return { sessionId: candidate.sessionId, workspaceId: candidate.workspaceId }; -} - -function sessionDeletedPayload( - payload: unknown, -): { sessionId: string; workspaceId: string } | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const candidate = payload as { sessionId?: unknown; workspaceId?: unknown }; - if (typeof candidate.sessionId !== 'string' || candidate.sessionId.length === 0) { - return undefined; - } - if (typeof candidate.workspaceId !== 'string' || candidate.workspaceId.length === 0) { - return undefined; - } - return { sessionId: candidate.sessionId, workspaceId: candidate.workspaceId }; -} - -function workspaceLifecyclePayload(payload: unknown): Workspace | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const candidate = (payload as { workspace?: unknown }).workspace; - if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) { - return undefined; - } - const ws = candidate as Partial; - if (typeof ws.id !== 'string' || ws.id.length === 0) return undefined; - if (typeof ws.root !== 'string' || ws.root.length === 0) return undefined; - if (typeof ws.name !== 'string') return undefined; - if (typeof ws.createdAt !== 'number' || typeof ws.lastOpenedAt !== 'number') return undefined; - return { - id: ws.id, - root: ws.root, - name: ws.name, - createdAt: ws.createdAt, - lastOpenedAt: ws.lastOpenedAt, - }; -} - -function workspaceDeletedPayload( - payload: unknown, -): { workspaceId: string; root: string } | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const candidate = payload as { workspaceId?: unknown; root?: unknown }; - if (typeof candidate.workspaceId !== 'string' || candidate.workspaceId.length === 0) { - return undefined; - } - if (typeof candidate.root !== 'string' || candidate.root.length === 0) return undefined; - return { workspaceId: candidate.workspaceId, root: candidate.root }; -} - -interface CapabilityChangedPayload { - capability_id: string; - install: { - running: boolean; - step?: string; - percent?: number; - error?: string; - note?: string; - }; -} - -function capabilityChangedPayload(payload: unknown): CapabilityChangedPayload | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const id = (payload as { capability_id?: unknown }).capability_id; - if (typeof id !== 'string' || id.length === 0) return undefined; - const install = (payload as { install?: unknown }).install; - if (typeof install !== 'object' || install === null) return undefined; - const running = (install as { running?: unknown }).running; - if (typeof running !== 'boolean') return undefined; - const out: CapabilityChangedPayload['install'] = { running }; - for (const key of ['step', 'error', 'note'] as const) { - const value = (install as Record)[key]; - if (typeof value === 'string') out[key] = value; - } - const percent = (install as { percent?: unknown }).percent; - if (typeof percent === 'number') out.percent = percent; - return { capability_id: id, install: out }; -} - -function configWarningPayload(payload: unknown): { warnings: ConfigWarningItem[] } | undefined { - if (typeof payload !== 'object' || payload === null) return undefined; - const warnings = (payload as { warnings?: unknown }).warnings; - if (!Array.isArray(warnings)) return undefined; - const items: ConfigWarningItem[] = []; - for (const warning of warnings) { - if (typeof warning !== 'object' || warning === null) return undefined; - const message = (warning as { message?: unknown }).message; - if (typeof message !== 'string' || message.length === 0) return undefined; - const domain = (warning as { domain?: unknown }).domain; - if (domain !== undefined && typeof domain !== 'string') return undefined; - items.push(typeof domain === 'string' ? { domain, message } : { message }); - } - return { warnings: items }; -} - -const configChangedPayloadSchema = configChangedEventSchema.omit({ type: true }); -const modelCatalogChangedPayloadSchema = modelCatalogChangedEventSchema.omit({ type: true }); - -function configChangedPayload( - payload: unknown, -): { changedFields: string[]; config: unknown } | undefined { - const parsed = configChangedPayloadSchema.safeParse(payload); - if (!parsed.success) return undefined; - return { changedFields: parsed.data.changedFields, config: parsed.data.config }; -} - -function modelCatalogChangedPayload( - payload: unknown, -): - | { - changed: ModelCatalogRefreshChange[]; - unchanged: string[]; - failed: ModelCatalogRefreshFailure[]; - } - | undefined { - const parsed = modelCatalogChangedPayloadSchema.safeParse(payload); - if (!parsed.success) return undefined; - return parsed.data; -} diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts b/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts deleted file mode 100644 index 83d41e297aa..00000000000 --- a/packages/kap-server/src/transport/ws/v1/sessionEventJournal.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { createReadStream } from 'node:fs'; -import { appendFile, mkdir } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { ulid } from 'ulid'; - -const JOURNAL_VERSION = 1; - -export interface EventEnvelope { - readonly type: string; - readonly seq: number; - readonly epoch?: string; - readonly volatile?: boolean; - readonly offset?: number; - readonly session_id?: string; - readonly timestamp: string; - readonly payload: unknown; -} - -interface JournalHeaderLine { - kind: 'journal_header'; - version: number; - epoch: string; - created_at: number; -} - -interface JournalEventLine { - kind: 'event'; - seq: number; - envelope: EventEnvelope; -} - -export interface JournalEntry { - seq: number; - envelope: EventEnvelope; -} - -export interface JournalLogger { - warn(obj: unknown, msg: string): void; - error?(obj: unknown, msg: string): void; -} - -const noopLogger: JournalLogger = { warn: () => {} }; - -export class SessionEventJournal { - private _seq: number; - private pendingLines: string[] = []; - private flushPromise: Promise | undefined; - private headerPending: boolean; - private closed = false; - - private constructor( - private readonly filePath: string, - private readonly logger: JournalLogger, - public readonly epoch: string, - lastSeq: number, - isFresh: boolean, - ) { - this._seq = lastSeq; - this.headerPending = isFresh; - } - - get seq(): number { - return this._seq; - } - - static async open(filePath: string, logger: JournalLogger = noopLogger): Promise { - let epoch: string | undefined; - let lastSeq = 0; - let sawAnyLine = false; - - try { - for await (const raw of readLines(filePath)) { - sawAnyLine = true; - const parsed = parseJournalLine(raw); - if (parsed === undefined) continue; - if (parsed.kind === 'journal_header') { - if (epoch === undefined) epoch = parsed.epoch; - continue; - } - if (parsed.seq > lastSeq) lastSeq = parsed.seq; - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'ENOENT') { - logger.warn( - { filePath, err: String(error) }, - 'event journal unreadable; starting a fresh epoch', - ); - } - } - - if (epoch === undefined) { - if (sawAnyLine) { - logger.warn({ filePath }, 'event journal missing header; rotating to a fresh epoch'); - } - return new SessionEventJournal(filePath, logger, `ep_${ulid()}`, 0, true); - } - return new SessionEventJournal(filePath, logger, epoch, lastSeq, false); - } - - nextSeq(): number { - this._seq += 1; - return this._seq; - } - - append(seq: number, envelope: EventEnvelope): void { - if (this.closed) return; - const line: JournalEventLine = { kind: 'event', seq, envelope }; - this.pendingLines.push(JSON.stringify(line)); - this.scheduleFlush(); - } - - async readSince(fromSeqExclusive: number, limit: number): Promise { - await this.flush(); - const out: JournalEntry[] = []; - try { - for await (const raw of readLines(this.filePath)) { - const parsed = parseJournalLine(raw); - if (parsed === undefined || parsed.kind !== 'event') continue; - if (parsed.seq <= fromSeqExclusive) continue; - out.push({ seq: parsed.seq, envelope: parsed.envelope }); - if (out.length >= limit) break; - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'ENOENT') throw error; - } - return out; - } - - async flush(): Promise { - while (this.flushPromise !== undefined || this.pendingLines.length > 0) { - if (this.flushPromise === undefined) { - this.flushPromise = this.flushOnce().finally(() => { - this.flushPromise = undefined; - }); - } - await this.flushPromise; - } - } - - async close(): Promise { - this.closed = true; - await this.flush(); - } - - private scheduleFlush(): void { - if (this.flushPromise !== undefined) return; - this.flushPromise = this.flushOnce().finally(() => { - this.flushPromise = undefined; - if (this.pendingLines.length > 0) this.scheduleFlush(); - }); - } - - private async flushOnce(): Promise { - const lines: string[] = []; - if (this.headerPending) { - const header: JournalHeaderLine = { - kind: 'journal_header', - version: JOURNAL_VERSION, - epoch: this.epoch, - created_at: Date.now(), - }; - lines.push(JSON.stringify(header)); - this.headerPending = false; - } - lines.push(...this.pendingLines); - this.pendingLines = []; - if (lines.length === 0) return; - try { - await mkdir(dirname(this.filePath), { recursive: true }); - await appendFile(this.filePath, lines.join('\n') + '\n', 'utf8'); - } catch (error) { - this.logger.warn( - { filePath: this.filePath, err: String(error) }, - 'event journal write failed; events remain live-only this round', - ); - } - } -} - -export function sessionJournalPath(eventsDir: string, sessionId: string): string { - return join(eventsDir, `${sessionId}.jsonl`); -} - -function parseJournalLine(raw: string): JournalHeaderLine | JournalEventLine | undefined { - const trimmed = raw.endsWith('\r') ? raw.slice(0, -1) : raw; - if (trimmed.length === 0) return undefined; - let value: unknown; - try { - value = JSON.parse(trimmed); - } catch { - return undefined; - } - if (typeof value !== 'object' || value === null) return undefined; - const kind = (value as { kind?: unknown }).kind; - if (kind === 'journal_header') { - const epoch = (value as { epoch?: unknown }).epoch; - if (typeof epoch !== 'string' || epoch.length === 0) return undefined; - return value as JournalHeaderLine; - } - if (kind === 'event') { - const seq = (value as { seq?: unknown }).seq; - const envelope = (value as { envelope?: unknown }).envelope; - if (typeof seq !== 'number' || !Number.isInteger(seq) || seq <= 0) return undefined; - if (typeof envelope !== 'object' || envelope === null) return undefined; - return value as JournalEventLine; - } - return undefined; -} - -async function* readLines(filePath: string): AsyncIterable { - let buffered = ''; - const stream = createReadStream(filePath, { encoding: 'utf8' }); - for await (const chunk of stream) { - buffered += chunk; - let newlineIndex = buffered.indexOf('\n'); - while (newlineIndex !== -1) { - yield buffered.slice(0, newlineIndex); - buffered = buffered.slice(newlineIndex + 1); - newlineIndex = buffered.indexOf('\n'); - } - } - if (buffered.length > 0) yield buffered; -} diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts deleted file mode 100644 index 8659a3fbe63..00000000000 --- a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { Event } from './events'; -import type { SnapshotSubagent } from '../../../protocol/rest-snapshot'; - -const MAIN_AGENT_ID = 'main'; - -export class SubagentRosterTracker { - private readonly bySession = new Map>(); - - apply(sessionId: string, event: Event): void { - switch (event.type) { - case 'subagent.spawned': { - if (event.runInBackground === true) return; - let roster = this.bySession.get(sessionId); - if (!roster) { - roster = new Map(); - this.bySession.set(sessionId, roster); - } - roster.set(event.subagentId, { - id: event.subagentId, - session_id: sessionId, - kind: 'subagent', - description: event.description ?? event.subagentName ?? 'Sub Agent', - status: 'running', - subagent_phase: 'queued', - subagent_type: event.subagentName, - parent_tool_call_id: event.parentToolCallId === '' ? undefined : event.parentToolCallId, - swarm_index: event.swarmIndex, - run_in_background: event.runInBackground, - model: event.model, - thinking_effort: event.thinkingEffort, - created_at: new Date().toISOString(), - }); - return; - } - case 'subagent.started': { - const entry = this.bySession.get(sessionId)?.get(event.subagentId); - if (!entry) return; - entry.subagent_phase = 'working'; - entry.suspended_reason = undefined; - entry.started_at ??= new Date().toISOString(); - return; - } - case 'subagent.suspended': { - const entry = this.bySession.get(sessionId)?.get(event.subagentId); - if (!entry) return; - entry.subagent_phase = 'suspended'; - entry.suspended_reason = event.reason; - return; - } - case 'subagent.completed': { - const entry = this.bySession.get(sessionId)?.get(event.subagentId); - if (!entry) return; - entry.subagent_phase = 'completed'; - entry.status = 'completed'; - entry.completed_at = new Date().toISOString(); - entry.output_preview = event.resultSummary; - return; - } - case 'subagent.failed': { - const entry = this.bySession.get(sessionId)?.get(event.subagentId); - if (!entry) return; - entry.subagent_phase = 'failed'; - entry.status = 'failed'; - entry.completed_at = new Date().toISOString(); - entry.output_preview = event.error; - return; - } - case 'task.started': { - const info = event.info; - if (info.kind === 'agent' && info.detached === true && info.agentId !== undefined) { - this.bySession.get(sessionId)?.delete(info.agentId); - } - return; - } - case 'turn.ended': { - if (event.agentId !== MAIN_AGENT_ID) return; - const roster = this.bySession.get(sessionId); - if (roster === undefined || event.reason === 'completed') return; - for (const entry of roster.values()) { - if (entry.status !== 'running') continue; - entry.status = 'failed'; - entry.subagent_phase = 'failed'; - entry.completed_at = new Date().toISOString(); - entry.output_preview ??= `Main turn ${event.reason}`; - } - return; - } - case 'turn.started': { - if (event.agentId === MAIN_AGENT_ID) { - this.bySession.delete(sessionId); - } - return; - } - default: - return; - } - } - - get(sessionId: string): SnapshotSubagent[] { - const roster = this.bySession.get(sessionId); - if (!roster) return []; - return Array.from(roster.values(), (entry) => ({ ...entry })); - } - - clear(sessionId: string): void { - this.bySession.delete(sessionId); - } -} diff --git a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts deleted file mode 100644 index 3da60b7fc44..00000000000 --- a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts +++ /dev/null @@ -1,570 +0,0 @@ -import { - unsubscribeV2PayloadSchema, - WS_PROTOCOL_VERSION, - type SessionCursor, -} from '../../../protocol/ws-control'; -import { - detachGrades, - transcriptSubscribeV2PayloadSchema, - type TranscriptGradeSpec, -} from '@moonshot-ai/transcript'; -import { ulid } from 'ulid'; -import type { RawData, WebSocket } from 'ws'; - -import type { CredentialValidator } from '../../../services/auth/credentials'; -import type { IConnectionRegistry } from '../connectionRegistry'; -import { - type EventEnvelope, - type JournalLogger, -} from './sessionEventJournal'; -import { - buildAck, - buildPing, - buildResyncRequired, - buildServerHello, -} from './protocol'; -import { - type AgentFilter, - type BroadcastDelivery, - type BroadcastTarget, - type ResyncReason, - type SessionEventBroadcaster, - type TargetSubscription, -} from './sessionEventBroadcaster'; - -const DEFAULT_MAX_BUFFER_SIZE = 1000; - -const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000; -const HEARTBEAT_MISS_LIMIT = 2; - -type SessionSubscription = TargetSubscription; - -const DEFAULT_FLUSH_INTERVAL_MS = 16; -const DEFAULT_MAX_BATCH_SIZE = 64; -const DEFAULT_HIGH_WATER_MARK_BYTES = 1 << 20; -const DEFAULT_BACKPRESSURE_RETRY_MS = 5; -const DEFAULT_BACKPRESSURE_MAX_DELAY_MS = 100; - -interface InboundFrame { - type: string; - id?: string; - payload?: Record; -} - -export interface WsConnectionV1Options { - readonly socket: WebSocket; - readonly broadcaster: SessionEventBroadcaster; - readonly connectionRegistry: IConnectionRegistry; - readonly validateCredential?: CredentialValidator; - readonly remoteAddress: string | null; - readonly userAgent: string | null; - readonly logger?: JournalLogger; - readonly maxBufferSize?: number; - readonly flushIntervalMs?: number; - readonly maxBatchSize?: number; - readonly highWaterMarkBytes?: number; - readonly heartbeatIntervalMs?: number; -} - -export class WsConnectionV1 implements BroadcastTarget { - readonly id: string; - readonly connectedAt: string; - readonly remoteAddress: string | null; - readonly userAgent: string | null; - - private readonly socket: WebSocket; - private readonly broadcaster: SessionEventBroadcaster; - private readonly validateCredential?: CredentialValidator; - private readonly maxBufferSize: number; - private readonly flushIntervalMs: number; - private readonly maxBatchSize: number; - private readonly highWaterMarkBytes: number; - private readonly heartbeatIntervalMs: number; - private readonly logger?: JournalLogger; - - private closed = false; - private gotClientHello = false; - readonly subscriptions = new Map(); - private controlQueue: Promise = Promise.resolve(); - - private outbound: unknown[] = []; - private flushTimer?: ReturnType; - private backpressureRetryTimer?: ReturnType; - private backpressureSince?: number; - - private heartbeatTimer?: ReturnType; - private lastInboundAt = Date.now(); - - constructor(opts: WsConnectionV1Options) { - this.id = `conn_${ulid()}`; - this.connectedAt = new Date().toISOString(); - this.remoteAddress = opts.remoteAddress; - this.userAgent = opts.userAgent; - this.socket = opts.socket; - this.broadcaster = opts.broadcaster; - this.validateCredential = opts.validateCredential; - this.logger = opts.logger; - this.maxBufferSize = opts.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE; - this.flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; - this.maxBatchSize = opts.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE; - this.highWaterMarkBytes = opts.highWaterMarkBytes ?? DEFAULT_HIGH_WATER_MARK_BYTES; - this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; - - this.socket.on('message', (data: RawData) => this.onMessage(data)); - this.socket.on('close', () => this.onClose()); - this.socket.on('error', () => this.onClose()); - - opts.connectionRegistry.add(this); - this.broadcaster.addGlobalTarget(this); - this.sendImmediateFrame( - buildServerHello({ - ws_connection_id: this.id, - protocol_version: WS_PROTOCOL_VERSION, - heartbeat_ms: this.heartbeatIntervalMs, - max_event_buffer_size: this.maxBufferSize, - capabilities: { event_batching: false, compression: false }, - }), - ); - this.heartbeatTimer = setInterval(() => { - this.onHeartbeat(); - }, this.heartbeatIntervalMs); - this.heartbeatTimer.unref?.(); - } - - get hasClientHello(): boolean { - return this.gotClientHello; - } - - get subscriptionSessionIds(): readonly string[] { - return Array.from(this.subscriptions.keys()).sort(); - } - - send(envelope: EventEnvelope, delivery: BroadcastDelivery = 'subscription'): void { - if (delivery === 'immediate') this.sendImmediateFrame(envelope); - else this.sendSubscribedFrame(envelope); - } - - private onMessage(data: RawData): void { - if (this.closed) return; - let frame: InboundFrame; - try { - frame = JSON.parse(rawDataToString(data)) as InboundFrame; - } catch { - return; - } - if (typeof frame?.type !== 'string') return; - this.lastInboundAt = Date.now(); - - switch (frame.type) { - case 'pong': - return; - case 'client_hello': - this.enqueueControl(() => this.onClientHello(frame)); - return; - case 'subscribe': - this.enqueueControl(() => this.onSubscribe(frame)); - return; - case 'subscribe_v2': - this.enqueueControl(() => this.onSubscribeV2(frame)); - return; - case 'unsubscribe_v2': - this.enqueueControl(() => this.onUnsubscribeV2(frame)); - return; - case 'unsubscribe': - this.enqueueControl(() => this.onUnsubscribe(frame)); - return; - default: - return; - } - } - - private enqueueControl(task: () => Promise): void { - this.controlQueue = this.controlQueue.then(task).catch(() => { - }); - } - - private onHeartbeat(): void { - if (Date.now() - this.lastInboundAt >= this.heartbeatIntervalMs * HEARTBEAT_MISS_LIMIT) { - this.close(1001, 'heartbeat timeout'); - return; - } - this.sendImmediateFrame(buildPing(ulid())); - } - - private async onClientHello(frame: InboundFrame): Promise { - if (!(await this.authorize(frame))) return; - this.gotClientHello = true; - - const payload = frame.payload ?? {}; - const subscriptions = asStringArray(payload['subscriptions']); - const cursors = payload['cursors'] as Record | undefined; - const agentFilter = parseAgentFilter(payload['agent_filter']); - - if (payload['client_id'] === 'kimi-inspect') this.broadcaster.addDiEventTarget(this); - - const accepted: string[] = []; - const resyncRequired: string[] = []; - const serverCursors: Record = {}; - - for (const sid of subscriptions) { - await this.attachSession( - sid, - cursors?.[sid], - agentFilter?.[sid], - this.subscriptions.get(sid)?.transcriptGrades, - undefined, - { accepted, resyncRequired, serverCursors }, - ); - } - - this.sendImmediateFrame( - buildAck(frame.id ?? '', 0, 'success', { - accepted_subscriptions: accepted, - resync_required: resyncRequired, - cursors: serverCursors, - }), - ); - } - - private async onSubscribe(frame: InboundFrame): Promise { - const payload = frame.payload ?? {}; - const sessionIds = asStringArray(payload['session_ids']); - const cursors = payload['cursors'] as Record | undefined; - const agentFilter = parseAgentFilter(payload['agent_filter']); - - const accepted: string[] = []; - const notFound: string[] = []; - const resyncRequired: string[] = []; - const serverCursors: Record = {}; - - for (const sid of sessionIds) { - await this.attachSession( - sid, - cursors?.[sid], - agentFilter?.[sid], - this.subscriptions.get(sid)?.transcriptGrades, - undefined, - { accepted, resyncRequired, serverCursors, notFound }, - ); - } - - this.sendImmediateFrame( - buildAck(frame.id ?? '', 0, 'success', { - accepted, - not_found: notFound, - resync_required: resyncRequired, - cursors: serverCursors, - }), - ); - } - - private async onSubscribeV2(frame: InboundFrame): Promise { - const parsed = transcriptSubscribeV2PayloadSchema.safeParse(frame.payload ?? {}); - if (!parsed.success) { - this.sendImmediateFrame(buildAck(frame.id ?? '', 1, 'invalid subscribe_v2 payload', {})); - return; - } - const sid = parsed.data.session_id; - - const accepted: string[] = []; - const notFound: string[] = []; - const resyncRequired: string[] = []; - const serverCursors: Record = {}; - - await this.attachSession( - sid, - undefined, - this.subscriptions.get(sid)?.agentFilter, - parsed.data.transcript, - parsed.data.transcript_since, - { accepted, resyncRequired, serverCursors, notFound }, - ); - - this.sendImmediateFrame( - buildAck(frame.id ?? '', 0, 'success', { - accepted, - not_found: notFound, - resync_required: resyncRequired, - cursors: serverCursors, - }), - ); - } - - private async onUnsubscribeV2(frame: InboundFrame): Promise { - const parsed = unsubscribeV2PayloadSchema.safeParse(frame.payload ?? {}); - if (!parsed.success) { - this.sendImmediateFrame(buildAck(frame.id ?? '', 1, 'invalid unsubscribe_v2 payload', {})); - return; - } - const sid = parsed.data.session_id; - const agentIds = parsed.data.agent_ids; - - const existing = this.subscriptions.get(sid); - if (existing !== undefined) { - this.broadcaster.unsubscribeTranscript(sid, this, agentIds); - this.subscriptions.set(sid, { - agentFilter: existing.agentFilter, - transcriptGrades: - agentIds === undefined ? undefined : detachGrades(existing.transcriptGrades, agentIds), - }); - } - - this.sendImmediateFrame( - buildAck(frame.id ?? '', 0, 'success', { - accepted: [sid], - not_found: [], - resync_required: [], - }), - ); - } - - private async onUnsubscribe(frame: InboundFrame): Promise { - const payload = frame.payload ?? {}; - const sessionIds = asStringArray(payload['session_ids']); - for (const sid of sessionIds) { - this.broadcaster.unsubscribe(sid, this); - this.subscriptions.delete(sid); - } - this.sendImmediateFrame( - buildAck(frame.id ?? '', 0, 'success', { - accepted: [], - not_found: [], - resync_required: [], - }), - ); - } - - private async attachSession( - sid: string, - cursor: SessionCursor | undefined, - filter: AgentFilter | undefined, - transcriptGrades: TranscriptGradeSpec | undefined, - transcriptSince: Record | undefined, - collectors: { - accepted: string[]; - resyncRequired: string[]; - serverCursors: Record; - notFound?: string[]; - }, - ): Promise { - const { accepted, resyncRequired, serverCursors, notFound } = collectors; - const ok = await this.broadcaster.subscribe(sid, this, filter, transcriptGrades, { - deferTranscriptReset: cursor !== undefined, - transcriptSince, - }); - if (!ok) { - if (notFound !== undefined) notFound.push(sid); - else resyncRequired.push(sid); - return; - } - this.subscriptions.set(sid, { agentFilter: filter, transcriptGrades }); - accepted.push(sid); - if (cursor !== undefined) { - await this.replay(sid, cursor, filter, transcriptGrades, resyncRequired, serverCursors); - await this.broadcaster.flushTranscriptSeed(sid, this); - } else { - const cur = await this.broadcaster.getCursor(sid); - serverCursors[sid] = cur; - } - } - - private async replay( - sid: string, - cursor: SessionCursor, - filter: AgentFilter | undefined, - transcriptGrades: TranscriptGradeSpec | undefined, - resyncRequired: string[], - serverCursors: Record, - ): Promise { - const result = await this.broadcaster.getBufferedSince(sid, cursor, filter, transcriptGrades); - if (result.resyncRequired !== false) { - this.sendImmediateFrame( - buildResyncRequired(sid, result.resyncRequired as ResyncReason, result.currentSeq, result.epoch), - ); - resyncRequired.push(sid); - } else { - for (const { envelope } of result.events) this.sendSubscribedFrame(envelope); - } - serverCursors[sid] = { seq: result.currentSeq, epoch: result.epoch }; - } - - private async authorize(frame: InboundFrame): Promise { - const payload = frame.payload ?? {}; - const token = typeof payload['token'] === 'string' ? (payload['token'] as string) : undefined; - if (token === undefined || this.validateCredential === undefined) return true; - let ok = false; - try { - ok = await this.validateCredential(token); - } catch { - ok = false; - } - if (!ok) { - this.sendImmediateFrame(buildAck(frame.id ?? '', 40112, 'unauthorized', {})); - this.close(); - return false; - } - return true; - } - - private sendSubscribedFrame(msg: unknown): void { - if (this.closed) return; - this.outbound.push(msg); - if (this.outbound.length >= this.maxBatchSize) { - this.flush(); - return; - } - this.scheduleFlush(); - } - - private sendImmediateFrame(msg: unknown): void { - if (this.closed) return; - this.outbound.push(msg); - this.flush(); - } - - private scheduleFlush(): void { - if (this.flushTimer !== undefined) return; - this.flushTimer = setTimeout(() => { - this.flushTimer = undefined; - this.flush(); - }, this.flushIntervalMs); - this.flushTimer.unref?.(); - } - - private flush(force = false): void { - if (this.flushTimer !== undefined) { - clearTimeout(this.flushTimer); - this.flushTimer = undefined; - } - if (this.outbound.length === 0) return; - if (this.closed || this.socket.readyState !== this.socket.OPEN) { - this.outbound = []; - return; - } - - if (!force && this.socket.bufferedAmount > this.highWaterMarkBytes) { - this.deferForBackpressure(); - return; - } - this.backpressureSince = undefined; - - const frames = coalesceFrames(this.outbound); - this.outbound = []; - for (const frame of frames) { - if (this.closed || this.socket.readyState !== this.socket.OPEN) return; - try { - this.socket.send(JSON.stringify(frame)); - } catch { - } - } - } - - private deferForBackpressure(): void { - const now = Date.now(); - if (this.backpressureSince === undefined) this.backpressureSince = now; - if (now - this.backpressureSince >= DEFAULT_BACKPRESSURE_MAX_DELAY_MS) { - this.flush(true); - return; - } - if (this.backpressureRetryTimer !== undefined) return; - this.backpressureRetryTimer = setTimeout(() => { - this.backpressureRetryTimer = undefined; - this.flush(); - }, DEFAULT_BACKPRESSURE_RETRY_MS); - this.backpressureRetryTimer.unref?.(); - } - - close(code = 1000, reason?: string): void { - if (this.closed) return; - this.flush(true); - try { - this.socket.close(code, reason); - } catch { - } - } - - private onClose(): void { - if (this.closed) return; - this.closed = true; - if (this.flushTimer !== undefined) clearTimeout(this.flushTimer); - if (this.backpressureRetryTimer !== undefined) clearTimeout(this.backpressureRetryTimer); - if (this.heartbeatTimer !== undefined) clearInterval(this.heartbeatTimer); - this.outbound = []; - this.broadcaster.removeGlobalTarget(this); - for (const sid of this.subscriptions.keys()) this.broadcaster.unsubscribe(sid, this); - } -} - -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.filter((v): v is string => typeof v === 'string'); -} - -function parseAgentFilter(value: unknown): Record | undefined { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; - const out: Record = {}; - for (const [sid, ids] of Object.entries(value)) { - if (!Array.isArray(ids)) continue; - const set = new Set(ids.filter((v): v is string => typeof v === 'string')); - if (set.size === 0) continue; - out[sid] = set; - } - return out; -} - -function rawDataToString(data: RawData): string { - if (typeof data === 'string') return data; - if (Buffer.isBuffer(data)) return data.toString('utf8'); - if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); - return Buffer.from(data as ArrayBuffer).toString('utf8'); -} - -interface CoalescableDelta { - type: 'assistant.delta' | 'thinking.delta'; - seq: number; - volatile: true; - offset?: number; - session_id?: string; - timestamp: string; - payload: { - agentId?: string; - turnId?: number; - delta: string; - [key: string]: unknown; - }; -} - -function isCoalescableDelta(frame: unknown): frame is CoalescableDelta { - if (typeof frame !== 'object' || frame === null) return false; - const f = frame as Record; - if (f['volatile'] !== true) return false; - const type = f['type']; - if (type !== 'assistant.delta' && type !== 'thinking.delta') return false; - const payload = f['payload']; - if (typeof payload !== 'object' || payload === null) return false; - return typeof (payload as Record)['delta'] === 'string'; -} - -export function coalesceFrames(frames: readonly unknown[]): unknown[] { - const out: unknown[] = []; - for (const frame of frames) { - const last = out.at(-1); - if ( - last !== undefined && - isCoalescableDelta(last) && - isCoalescableDelta(frame) && - last.type === frame.type && - last.session_id === frame.session_id && - last.payload.agentId === frame.payload.agentId && - last.payload.turnId === frame.payload.turnId - ) { - out[out.length - 1] = { - ...last, - payload: { ...last.payload, delta: last.payload.delta + frame.payload.delta }, - }; - } else { - out.push(frame); - } - } - return out; -} diff --git a/packages/kap-server/src/transport/ws/v3/globalTranslator.ts b/packages/kap-server/src/transport/ws/v3/globalTranslator.ts index e76b0c6924a..d24b67c56c7 100644 --- a/packages/kap-server/src/transport/ws/v3/globalTranslator.ts +++ b/packages/kap-server/src/transport/ws/v3/globalTranslator.ts @@ -8,8 +8,10 @@ import type { WsV3CoreEvent, WsV3GlobalSource, WsV3Logger } from './wsV3Deps'; export class GlobalMessageTranslator { private queue: Promise = Promise.resolve(); private readonly workspaces = new Map(); + private readonly sessions = new Map(); private readonly validationFailures = new Map(); private readonly disposable: IDisposable; + private readonly activityDisposable: IDisposable | undefined; private disposed = false; constructor( @@ -18,6 +20,9 @@ export class GlobalMessageTranslator { private readonly logger?: WsV3Logger, ) { this.disposable = deps.subscribe((event) => this.onEvent(event)); + this.activityDisposable = deps.watchSessionActivity?.((sessionId) => + this.onSessionActivity(sessionId), + ); this.enqueue(async () => { for (const workspace of await deps.listWorkspaces()) { this.workspaces.set(workspace.id, await deps.workspaceInfo(workspace)); @@ -28,7 +33,19 @@ export class GlobalMessageTranslator { dispose(): void { this.disposed = true; this.disposable.dispose(); + this.activityDisposable?.dispose(); this.workspaces.clear(); + this.sessions.clear(); + } + + private onSessionActivity(sessionId: string): void { + this.enqueue(async () => { + if (this.disposed) return; + const session = await this.deps.sessionInfo(sessionId); + if (session === undefined) return; + this.sessions.set(sessionId, session); + this.emitValidated({ type: 'session', timestamp: Date.now(), subtype: 'updated', session }); + }); } private onEvent(event: WsV3CoreEvent): void { @@ -118,6 +135,7 @@ export class GlobalMessageTranslator { if (payload === undefined || sessionId === undefined) return []; const session = payload['session'] ?? (await this.deps.sessionInfo(sessionId)); if (typeof session !== 'object' || session === null) return []; + this.sessions.set(sessionId, session); return [{ type: 'session', timestamp, subtype: 'created', session }]; } case 'event.session.archived': { @@ -125,14 +143,24 @@ export class GlobalMessageTranslator { if (sessionId === undefined) return []; const session = await this.deps.sessionInfo(sessionId); if (session === undefined) return []; + this.sessions.set(sessionId, session); return [{ type: 'session', timestamp, subtype: 'archived', session }]; } + case 'event.session.deleted': { + const sessionId = stringField(asRecord(event.payload), 'sessionId'); + if (sessionId === undefined) return []; + const cached = this.sessions.get(sessionId); + this.sessions.delete(sessionId); + if (cached === undefined) return []; + return [{ type: 'session', timestamp, subtype: 'deleted', session: cached }]; + } case 'session.meta.updated': { const payload = asRecord(event.payload); const sessionId = stringField(payload, 'sessionId'); if (payload === undefined || sessionId === undefined) return []; const session = await this.deps.sessionInfo(sessionId); if (session === undefined) return []; + this.sessions.set(sessionId, session); return [ { type: 'session', diff --git a/packages/kap-server/src/transport/ws/v3/registerWsV3.ts b/packages/kap-server/src/transport/ws/v3/registerWsV3.ts index b06321f916e..13390e51056 100644 --- a/packages/kap-server/src/transport/ws/v3/registerWsV3.ts +++ b/packages/kap-server/src/transport/ws/v3/registerWsV3.ts @@ -1,8 +1,11 @@ import { IEventService, + ISessionActivityView, ISessionIndex, ISessionManager, IWorkspaceService, + getLiveSessionById, + type IDisposable, type Scope, type Workspace, } from '@moonshot-ai/agent-core-v2'; @@ -66,6 +69,42 @@ export function registerWsV3(core: Scope, opts: RegisterWsV3Options): WsV3Regist if (cwd === undefined) return undefined; return toWireSession(summary, cwd, resolveSessionFacts(core, sessionId)); }, + watchSessionActivity(listener) { + const watchers = new Map(); + const drop = (sessionId: string): void => { + watchers.get(sessionId)?.dispose(); + watchers.delete(sessionId); + }; + const attach = (sessionId: string): void => { + if (watchers.has(sessionId)) return; + const handle = getLiveSessionById(core.accessor, sessionId); + if (handle === undefined) return; + watchers.set( + sessionId, + handle.accessor.get(ISessionActivityView).onDidChange(() => listener(sessionId)), + ); + }; + const eventDisposable = core.accessor.get(IEventService).subscribe((event) => { + const payload = (event as { readonly payload?: unknown }).payload; + if (typeof payload !== 'object' || payload === null) return; + const sessionId = (payload as { readonly sessionId?: unknown }).sessionId; + if (event.type === 'event.session.archived' || event.type === 'event.session.deleted') { + if (typeof sessionId === 'string') drop(sessionId); + return; + } + if (typeof sessionId === 'string' && sessionId.length > 0) attach(sessionId); + }); + const manager = core.accessor.get(ISessionManager); + const closeDisposable = manager.onDidCloseSession?.((event) => drop(event.sessionId)); + return { + dispose: () => { + eventDisposable.dispose(); + closeDisposable?.dispose(); + for (const watcher of watchers.values()) watcher.dispose(); + watchers.clear(); + }, + }; + }, }; const hub = new WsV3Hub({ projection: opts.projection, diff --git a/packages/kap-server/src/transport/ws/v3/wsV3Deps.ts b/packages/kap-server/src/transport/ws/v3/wsV3Deps.ts index 5e4097b1753..589ced1abc1 100644 --- a/packages/kap-server/src/transport/ws/v3/wsV3Deps.ts +++ b/packages/kap-server/src/transport/ws/v3/wsV3Deps.ts @@ -29,4 +29,5 @@ export interface WsV3GlobalSource { listWorkspaces(): Promise; workspaceInfo(workspace: Workspace): Promise; sessionInfo(sessionId: string): Promise; + watchSessionActivity?(listener: (sessionId: string) => void): IDisposable; } diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 0181975705d..4725b8818f4 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -8,11 +8,6 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "/", 404, ], - [ - "GET", - "/asyncapi.json", - 200, - ], [ "GET", "/openapi.json", @@ -216,14 +211,6 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/sessions/{session_id}/media/{file_id}", ], - [ - "GET", - "/api/v1/sessions/{session_id}/messages", - ], - [ - "GET", - "/api/v1/sessions/{session_id}/messages/{message_id}", - ], [ "GET", "/api/v1/sessions/{session_id}/profile", @@ -244,10 +231,6 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/sessions/{session_id}/skills", ], - [ - "GET", - "/api/v1/sessions/{session_id}/snapshot", - ], [ "GET", "/api/v1/sessions/{session_id}/status", @@ -268,22 +251,6 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/sessions/{session_id}/terminals/{terminal_id}", ], - [ - "GET", - "/api/v1/sessions/{session_id}/transcript", - ], - [ - "GET", - "/api/v1/sessions/{session_id}/transcript/ops", - ], - [ - "GET", - "/api/v1/sessions/{session_id}/transcript/plan", - ], - [ - "GET", - "/api/v1/sessions/{session_id}/transcript/user-messages", - ], [ "GET", "/api/v1/sessions/{session_id}/warnings", @@ -320,10 +287,6 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v2/sessions", ], - [ - "GET", - "/asyncapi.json", - ], [ "GET", "/openapi.json", diff --git a/packages/kap-server/test/apiSurface.snapshot.test.ts b/packages/kap-server/test/apiSurface.snapshot.test.ts index 0f0688c168d..609600d8c12 100644 --- a/packages/kap-server/test/apiSurface.snapshot.test.ts +++ b/packages/kap-server/test/apiSurface.snapshot.test.ts @@ -19,7 +19,7 @@ const HTTP_METHODS = new Set([ 'trace', ]); -const META_ENDPOINTS = ['/openapi.json', '/asyncapi.json', '/']; +const META_ENDPOINTS = ['/openapi.json', '/']; describe('API surface snapshot', () => { let home: string | undefined; diff --git a/packages/kap-server/test/authWiring.e2e.test.ts b/packages/kap-server/test/authWiring.e2e.test.ts index 23481144c86..dcc19422931 100644 --- a/packages/kap-server/test/authWiring.e2e.test.ts +++ b/packages/kap-server/test/authWiring.e2e.test.ts @@ -116,27 +116,13 @@ describe('production auth wiring', () => { expect(body.code).toBe(40101); }); - it('gates /asyncapi.json: 200 with the token, 401 without', async () => { + it('gates WS: hello with the token, rejected without', async () => { const token = (await readFile(join(home as string, 'server.token'), 'utf8')).trim(); - - const ok = await fetch(`${base}/asyncapi.json`, { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(ok.status).toBe(200); - const doc = (await ok.json()) as { asyncapi?: string }; - expect(doc.asyncapi).toBeDefined(); - - const bad = await fetch(`${base}/asyncapi.json`); - expect(bad.status).toBe(401); - }); - - it('gates WS: server_hello with the token, rejected without', async () => { - const token = (await readFile(join(home as string, 'server.token'), 'utf8')).trim(); - const wsUrl = `ws://127.0.0.1:${(server as RunningServer).port}/api/v1/ws`; + const wsUrl = `ws://127.0.0.1:${(server as RunningServer).port}/api/v3/ws`; const { ws, firstFrame } = await openConn(wsUrl, [`kimi-code.bearer.${token}`]); sockets.push(ws); - expect(firstFrame).toMatchObject({ type: 'server_hello' }); + expect(firstFrame).toMatchObject({ type: 'hello' }); await expectRejected(wsUrl); }); diff --git a/packages/kap-server/test/config.test.ts b/packages/kap-server/test/config.test.ts index e1ea4268a99..55545eddcd4 100644 --- a/packages/kap-server/test/config.test.ts +++ b/packages/kap-server/test/config.test.ts @@ -221,26 +221,23 @@ describe('server-v2 config changed WS notifications', () => { } interface ConfigChangedFrame { - type: 'event.config.changed'; - payload: { - changedFields: string[]; - config: Record; - }; + type: 'config'; + config: Record; + changed_fields?: string[]; } async function openWs(): Promise { const live = server as RunningServer; - const ws = new WebSocket(`ws://127.0.0.1:${live.port}/api/v1/ws`, [ + const ws = new WebSocket(`ws://127.0.0.1:${live.port}/api/v3/ws`, [ `kimi-code.bearer.${bearerToken(live)}`, ]); sockets.push(ws); const frames: ConfigChangedFrame[] = []; ws.on('message', (data) => { const frame = JSON.parse((data as Buffer).toString()) as { type?: string }; - if (frame.type === 'event.config.changed') frames.push(frame as ConfigChangedFrame); + if (frame.type === 'config') frames.push(frame as ConfigChangedFrame); }); await new Promise((resolve) => ws.on('open', resolve)); - ws.send(JSON.stringify({ type: 'client_hello', payload: { client_id: 'config-ws-test' } })); return frames; } @@ -263,10 +260,10 @@ describe('server-v2 config changed WS notifications', () => { await vi.waitFor(() => expect(frames.length).toBeGreaterThanOrEqual(1)); const last = frames.at(-1) as ConfigChangedFrame; - expect(last.payload.changedFields).toEqual(['defaultPermissionMode']); - expect(last.payload.config['default_permission_mode']).toBe('yolo'); - expect(last.payload.config['yolo']).toBe(true); - expect(last.payload.config).toHaveProperty('providers'); + expect(last.changed_fields).toEqual(['defaultPermissionMode']); + expect(last.config['default_permission_mode']).toBe('yolo'); + expect(last.config['yolo']).toBe(true); + expect(last.config).toHaveProperty('providers'); }); it('publishes camelCase changedFields on the engine write path used by OAuth refreshes', async () => { @@ -279,8 +276,8 @@ describe('server-v2 config changed WS notifications', () => { await vi.waitFor(() => expect(frames.length).toBeGreaterThanOrEqual(1)); const last = frames.at(-1) as ConfigChangedFrame; - expect(last.payload.changedFields).toEqual(['defaultModel']); - expect(last.payload.config['default_model']).toBe('k2'); + expect(last.changed_fields).toEqual(['defaultModel']); + expect(last.config['default_model']).toBe('k2'); }); it('publishes an event when config.toml is edited outside the process and reloaded', async () => { @@ -298,8 +295,8 @@ describe('server-v2 config changed WS notifications', () => { await vi.waitFor(() => expect(frames.length).toBeGreaterThanOrEqual(1), { timeout: 10000 }); const last = frames.at(-1) as ConfigChangedFrame; - expect(last.payload.changedFields).toContain('defaultPermissionMode'); - expect(last.payload.config['default_permission_mode']).toBe('yolo'); + expect(last.changed_fields).toContain('defaultPermissionMode'); + expect(last.config['default_permission_mode']).toBe('yolo'); }); it('closes the config publisher before the app, so a pending change is never delivered during shutdown', async () => { diff --git a/packages/kap-server/test/connections.test.ts b/packages/kap-server/test/connections.test.ts index ce3aa1a65a4..de6e88d0a92 100644 --- a/packages/kap-server/test/connections.test.ts +++ b/packages/kap-server/test/connections.test.ts @@ -27,7 +27,7 @@ describe('server-v2 GET /api/v1/connections', () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-connections-')); server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; - wsUrl = `ws://127.0.0.1:${server.port}/api/v1/ws`; + wsUrl = `ws://127.0.0.1:${server.port}/api/v3/ws`; }); afterAll(async () => { @@ -88,7 +88,7 @@ describe('server-v2 GET /api/v1/connections', () => { expect(connections).toEqual([]); }); - it('lists a raw connection without hello', async () => { + it('lists a raw connection', async () => { const ws = await connect(); const closed = new Promise((res) => ws.on('close', () => res())); await waitForSize(1); @@ -97,7 +97,7 @@ describe('server-v2 GET /api/v1/connections', () => { expect(connections).toHaveLength(1); const c = connections[0]!; expect(c.id).toMatch(/^conn_/); - expect(c.has_client_hello).toBe(false); + expect(c.has_client_hello).toBe(true); expect(c.subscriptions).toEqual([]); expect(c.connected_at).toMatch(/Z$/); expect(typeof c.remote_address).toBe('string'); @@ -107,15 +107,11 @@ describe('server-v2 GET /api/v1/connections', () => { await closed; }); - it('reflects client_hello and session subscriptions', async () => { + it('reflects session subscriptions', async () => { const sessionId = await createSession(home as string); const ws = await connect(); try { - send(ws, { - type: 'client_hello', - id: 'h1', - payload: { client_id: 'connections-test', subscriptions: [sessionId] }, - }); + send(ws, { type: 'subscribe', id: 1, session_id: sessionId }); await new Promise((r) => setTimeout(r, 50)); let connections = await listConnections(); @@ -124,7 +120,7 @@ describe('server-v2 GET /api/v1/connections', () => { expect(c.has_client_hello).toBe(true); expect(c.subscriptions).toContain(sessionId); - send(ws, { type: 'unsubscribe', id: 'u1', payload: { session_ids: [sessionId] } }); + send(ws, { type: 'unsubscribe', id: 2, session_id: sessionId }); await new Promise((r) => setTimeout(r, 50)); connections = await listConnections(); expect(connections[0]!.subscriptions).not.toContain(sessionId); @@ -135,11 +131,6 @@ describe('server-v2 GET /api/v1/connections', () => { it('removes the connection after the socket closes', async () => { const ws = await connect(); - send(ws, { - type: 'client_hello', - id: 'h1', - payload: { client_id: 'connections-test', subscriptions: [] }, - }); await waitForSize(1); ws.close(); diff --git a/packages/kap-server/test/disableAuth.e2e.test.ts b/packages/kap-server/test/disableAuth.e2e.test.ts index aa4360a1cfc..6d0e8c8b837 100644 --- a/packages/kap-server/test/disableAuth.e2e.test.ts +++ b/packages/kap-server/test/disableAuth.e2e.test.ts @@ -87,9 +87,9 @@ describe('server-v2 disableAuth (--dangerous-bypass-auth)', () => { }); it('disableAuth:true lets WebSocket upgrades through without a token', async () => { - const v1 = await openConn(`ws://127.0.0.1:${server!.port}/api/v1/ws`); - sockets.push(v1.ws); - expect(v1.firstFrame).toMatchObject({ type: 'server_hello' }); + const v3 = await openConn(`ws://127.0.0.1:${server!.port}/api/v3/ws`); + sockets.push(v3.ws); + expect(v3.firstFrame).toMatchObject({ type: 'hello' }); }); it('default boot keeps the gate closed and reports dangerous_bypass_auth: false', async () => { diff --git a/packages/kap-server/test/inFlightTurnTracker.test.ts b/packages/kap-server/test/inFlightTurnTracker.test.ts deleted file mode 100644 index 28e0fa406e3..00000000000 --- a/packages/kap-server/test/inFlightTurnTracker.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { Event } from '../src/transport/ws/v1/events'; -import { describe, expect, it } from 'vitest'; - -import { InFlightTurnTracker } from '../src/transport/ws/v1/inFlightTurnTracker'; - -const SID = 'sess_1'; - -function ev(partial: Record): Event { - return { agentId: 'main', sessionId: SID, ...partial } as unknown as Event; -} - -describe('InFlightTurnTracker', () => { - it('accumulates assistant text and reports pre-append offsets', () => { - const t = new InFlightTurnTracker(); - t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); - - expect(t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'Hello' }))).toEqual({ - offset: 0, - }); - expect(t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: ' world' }))).toEqual({ - offset: 5, - }); - - expect(t.get(SID)).toMatchObject({ turn_id: 1, assistant_text: 'Hello world' }); - }); - - it('tracks thinking offsets independently', () => { - const t = new InFlightTurnTracker(); - t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); - expect(t.apply(SID, ev({ type: 'thinking.delta', turnId: 1, delta: 'abc' }))).toEqual({ - offset: 0, - }); - expect(t.apply(SID, ev({ type: 'thinking.delta', turnId: 1, delta: 'de' }))).toEqual({ - offset: 3, - }); - expect(t.get(SID)).toMatchObject({ assistant_text: '', thinking_text: 'abcde' }); - }); - - it('clears on turn.ended', () => { - const t = new InFlightTurnTracker(); - t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); - t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'x' })); - t.apply(SID, ev({ type: 'turn.ended', turnId: 1 })); - expect(t.get(SID)).toBeNull(); - }); - - it('ignores non-main agents', () => { - const t = new InFlightTurnTracker(); - t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); - const sub = { agentId: 'agent-sub', sessionId: SID, type: 'assistant.delta', turnId: 1, delta: 'nope' } as unknown as Event; - expect(t.apply(SID, sub)).toEqual({}); - expect(t.get(SID)?.assistant_text).toBe(''); - }); - - it('ignores deltas for a mismatched turn', () => { - const t = new InFlightTurnTracker(); - t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); - expect(t.apply(SID, ev({ type: 'assistant.delta', turnId: 99, delta: 'stale' }))).toEqual({}); - expect(t.get(SID)?.assistant_text).toBe(''); - }); - - it('tracks running tools and their last progress', () => { - const t = new InFlightTurnTracker(); - t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); - t.apply(SID, ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'tc1', name: 'bash' })); - t.apply( - SID, - ev({ type: 'tool.progress', turnId: 1, toolCallId: 'tc1', update: { kind: 'stdout', text: 'hi' } }), - ); - expect(t.get(SID)?.running_tools).toEqual([ - { tool_call_id: 'tc1', name: 'bash', last_progress: { kind: 'stdout', text: 'hi' } }, - ]); - t.apply(SID, ev({ type: 'tool.result', turnId: 1, toolCallId: 'tc1' })); - expect(t.get(SID)?.running_tools).toEqual([]); - }); - - it('resets text accumulation at step boundaries (step-relative in-flight text)', () => { - const t = new InFlightTurnTracker(); - t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); - t.apply(SID, ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - t.apply(SID, ev({ type: 'thinking.delta', turnId: 1, delta: 'think-1' })); - t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'text-1' })); - t.apply(SID, ev({ type: 'turn.step.completed', turnId: 1, step: 1 })); - - t.apply(SID, ev({ type: 'turn.step.started', turnId: 1, step: 2 })); - t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'text-2' })); - - expect(t.get(SID)).toMatchObject({ assistant_text: 'text-2', thinking_text: '' }); - }); - - it('reports step-relative offsets that restart at 0 each step', () => { - const t = new InFlightTurnTracker(); - t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); - t.apply(SID, ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - expect(t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'ab' }))).toEqual({ offset: 0 }); - expect(t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'cd' }))).toEqual({ offset: 2 }); - - t.apply(SID, ev({ type: 'turn.step.started', turnId: 1, step: 2 })); - expect(t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'x' }))).toEqual({ offset: 0 }); - }); - - it('keeps running tools across step boundaries while resetting text', () => { - const t = new InFlightTurnTracker(); - t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); - t.apply(SID, ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - t.apply(SID, ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'tc1', name: 'bash' })); - t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'text-1' })); - - t.apply(SID, ev({ type: 'turn.step.started', turnId: 1, step: 2 })); - - expect(t.get(SID)?.assistant_text).toBe(''); - expect(t.get(SID)?.running_tools).toEqual([{ tool_call_id: 'tc1', name: 'bash' }]); - }); - - it('ignores step boundaries for a mismatched turn', () => { - const t = new InFlightTurnTracker(); - t.apply(SID, ev({ type: 'turn.started', turnId: 1 })); - t.apply(SID, ev({ type: 'assistant.delta', turnId: 1, delta: 'keep' })); - t.apply(SID, ev({ type: 'turn.step.started', turnId: 99, step: 2 })); - expect(t.get(SID)?.assistant_text).toBe('keep'); - }); -}); diff --git a/packages/kap-server/test/mediaRefParity.test.ts b/packages/kap-server/test/mediaRefParity.test.ts deleted file mode 100644 index 5f9d1322d52..00000000000 --- a/packages/kap-server/test/mediaRefParity.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - daemonFileRefFromPart as engineRefFromPart, - matchSingleMediaPathTag as engineMatchTag, - parseDaemonFileUrl as engineParse, -} from '@moonshot-ai/agent-core-v2'; -import { - daemonFileRefFromPairingPart as mirrorRefFromPart, - matchMediaPathTagText as mirrorMatchTag, - parseDaemonFileRef as mirrorParse, - type MediaRefPart, -} from '@moonshot-ai/transcript'; -import type { ContentPart } from '@moonshot-ai/agent-core-v2/human/llm/message'; - -const URLS = [ - 'kimi-file://f_1?path=%2Fcache%2Fshot.png', - 'kimi-file://f_1', - 'kimi-file://f_1?path=', - 'kimi-file://?path=%2Fcache%2Fshot.png', - 'kimi-file://f_1?path=%2Fcache%2Fa%20%26%20%22b%22%20%3Cc%3E.png', - 'kimi-file://f_1?path=%zz', - 'kimi-file://', - 'https://example.com/shot.png', - '', -]; - -describe('daemon file url parsing parity (engine vs transcript mirror)', () => { - for (const url of URLS) { - it(JSON.stringify(url), () => { - expect(mirrorParse(url)).toEqual(engineParse(url)); - }); - } -}); - -const TAG_TEXTS = [ - '', - '', - '', - ' \n', - '', - 'open please', - '', - 'plain text', - '', -]; - -describe('standalone media path tag matching parity', () => { - for (const text of TAG_TEXTS) { - it(JSON.stringify(text), () => { - const engine = engineMatchTag(text); - const mirror = mirrorMatchTag(text); - expect(mirror === undefined ? undefined : { kind: mirror.kind, path: mirror.path }).toEqual( - engine === undefined ? undefined : { kind: engine.kind, path: engine.path }, - ); - }); - } -}); - -const PARTS: ReadonlyArray = [ - { type: 'image_url', imageUrl: { url: 'kimi-file://f_1?path=%2Fcache%2Fshot.png' } }, - { type: 'video_url', videoUrl: { url: 'kimi-file://f_3?path=%2Fcache%2Fclip.mp4' } }, - { type: 'image_url', imageUrl: { url: 'kimi-file://f_1' } }, - { type: 'image_url', imageUrl: { url: 'https://example.com/shot.png' } }, - { type: 'text', text: '' }, - { type: 'text', text: 'hello' }, -]; - -describe('daemon ref extraction parity', () => { - for (const [index, part] of PARTS.entries()) { - it(`part ${index}: ${part.type}`, () => { - expect(mirrorRefFromPart(part)).toEqual( - engineRefFromPart(part as unknown as ContentPart), - ); - }); - } -}); diff --git a/packages/kap-server/test/messages.test.ts b/packages/kap-server/test/messages.test.ts deleted file mode 100644 index 83a79424336..00000000000 --- a/packages/kap-server/test/messages.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { - IAgentContextMemoryService, - IAgentLifecycleService, - IWireService, - getLiveSessionById, - IModelCatalog, - type ContextMessage, - type ScopeSeed, -} from '@moonshot-ai/agent-core-v2'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -import { type RunningServer, startServer } from '../src/start'; -import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; -import { authHeaders } from './helpers/auth'; - -interface Envelope { - code: number; - msg: string; - data: T; - request_id: string; - details?: { path: string; message: string }[]; -} - -interface MessageWire { - id: string; - session_id: string; - role: string; - content: { type: string; [key: string]: unknown }[]; - created_at: string; - metadata?: Record; -} - -interface PageWire { - items: MessageWire[]; - has_more: boolean; -} - -const MSG_ID = /^msg_.+/; - -describe('server-v2 /api/v1/sessions/{sid}/messages', () => { - let server: RunningServer | undefined; - let home: string | undefined; - let base: string; - let seeds: ScopeSeed | undefined; - - beforeAll(async () => { - home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-messages-')); - const modelCatalog: IModelCatalog = { - _serviceBrand: undefined, - get: () => { - throw new Error('modelCatalog.get not exercised in this test'); - }, - getRequester: () => { - throw new Error('modelCatalog.getRequester not exercised in this test'); - }, - generate: () => { - throw new Error('modelCatalog.generate not exercised in this test'); - }, - ping: () => { - throw new Error('modelCatalog.ping not exercised in this test'); - }, - findByName: () => [], - listModels: async () => [], - listProviders: async () => [], - getProvider: async () => { - throw new Error('modelCatalog.getProvider not exercised in this test'); - }, - setDefaultModel: async () => { - throw new Error('modelCatalog.setDefaultModel not exercised in this test'); - }, - }; - seeds = [[IModelCatalog, modelCatalog]]; - await boot(); - }); - - async function boot(): Promise { - server = await startServer({ - hostIdentity: TEST_HOST_IDENTITY, - host: '127.0.0.1', - port: 0, - homeDir: home as string, - logLevel: 'silent', - seeds, - }); - base = `http://127.0.0.1:${server.port}`; - } - - afterAll(async () => { - if (server !== undefined) { - await server.close(); - server = undefined; - } - if (home !== undefined) { - await rm(home, { recursive: true, force: true }); - home = undefined; - } - }); - - async function getJson(path: string): Promise<{ status: number; body: Envelope }> { - const res = await fetch(`${base}${path}`, { - headers: authHeaders(server as RunningServer), - } as never); - return { status: res.status, body: (await res.json()) as Envelope }; - } - - async function createSession(): Promise { - const res = await fetch(`${base}/api/v1/sessions`, { - method: 'POST', - headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), - body: JSON.stringify({ metadata: { cwd: home as string } }), - } as never); - const body = (await res.json()) as Envelope<{ id: string }>; - expect(body.code).toBe(0); - return body.data.id; - } - - async function seedMainAgentMessages( - sessionId: string, - messages: readonly ContextMessage[], - ): Promise { - const session = getLiveSessionById(server!.core.accessor, sessionId); - if (session === undefined) throw new Error(`session ${sessionId} not found`); - let agent = session.accessor.get(IAgentLifecycleService).handleOf('main'); - if (agent === undefined) { - await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); - agent = session.accessor.get(IAgentLifecycleService).handleOf('main')!; - } - if (messages.length > 0) { - agent.accessor.get(IAgentContextMemoryService).append(...messages); - await agent.accessor.get(IWireService).flush(); - } - } - - it('returns an empty page when the session has no main agent', async () => { - const id = await createSession(); - const { body } = await getJson(`/api/v1/sessions/${id}/messages`); - expect(body.code).toBe(0); - expect(body.data.items).toEqual([]); - expect(body.data.has_more).toBe(false); - }); - - it('returns an empty page when the main agent has no messages yet', async () => { - const id = await createSession(); - await seedMainAgentMessages(id, []); - const { body } = await getJson(`/api/v1/sessions/${id}/messages`); - expect(body.code).toBe(0); - expect(body.data.items).toEqual([]); - }); - - it('lists spliced messages newest-first with stable ids and mapped content', async () => { - const id = await createSession(); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, - { - role: 'assistant', - content: [{ type: 'text', text: 'running' }], - toolCalls: [{ type: 'function', id: 'call_1', name: 'Bash', arguments: '{"cmd":"ls"}' }], - }, - { role: 'tool', content: [{ type: 'text', text: 'file.txt' }], toolCalls: [], toolCallId: 'call_1' }, - ]); - - const { body } = await getJson(`/api/v1/sessions/${id}/messages`); - expect(body.code).toBe(0); - expect(body.data.has_more).toBe(false); - expect(body.data.items).toHaveLength(3); - expect(body.data.items.every((m) => MSG_ID.test(m.id))).toBe(true); - expect(body.data.items.every((m) => m.session_id === id)).toBe(true); - - const [tool, assistant, user] = body.data.items; - - expect(user).toMatchObject({ - role: 'user', - content: [{ type: 'text', text: 'hi' }], - }); - - expect(assistant).toMatchObject({ - role: 'assistant', - content: [ - { type: 'text', text: 'running' }, - { - type: 'tool_use', - tool_call_id: 'call_1', - tool_name: 'Bash', - input: { cmd: 'ls' }, - }, - ], - }); - - expect(tool).toMatchObject({ - role: 'tool', - content: [{ type: 'tool_result', tool_call_id: 'call_1', output: 'file.txt' }], - }); - }); - - it('gets a single message by id and 404s for an unknown message', async () => { - const id = await createSession(); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, - { role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, - ]); - - const list = await getJson(`/api/v1/sessions/${id}/messages`); - const assistant = list.body.data.items.find((m) => m.role === 'assistant'); - expect(assistant).toBeDefined(); - - const got = await getJson( - `/api/v1/sessions/${id}/messages/${assistant!.id}`, - ); - expect(got.body.code).toBe(0); - expect(got.body.data).toMatchObject({ - id: assistant!.id, - role: 'assistant', - content: [{ type: 'text', text: 'hello' }], - }); - - const missing = await getJson( - `/api/v1/sessions/${id}/messages/msg_does_not_exist`, - ); - expect(missing.body.code).toBe(40403); - }); - - it('returns 40403 for a message id not present in the session', async () => { - const id = await createSession(); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, - ]); - const { body } = await getJson( - `/api/v1/sessions/${id}/messages/msg_00NOT_IN_SESSION00`, - ); - expect(body.code).toBe(40403); - }); - - it('returns 40401 for an unknown session on both endpoints', async () => { - const list = await getJson('/api/v1/sessions/nope/messages'); - expect(list.body.code).toBe(40401); - - const got = await getJson('/api/v1/sessions/nope/messages/msg_does_not_exist'); - expect(got.body.code).toBe(40401); - }); - - it('paginates with page_size and before_id / after_id cursors', async () => { - const id = await createSession(); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'm0' }], toolCalls: [] }, - { role: 'user', content: [{ type: 'text', text: 'm1' }], toolCalls: [] }, - { role: 'user', content: [{ type: 'text', text: 'm2' }], toolCalls: [] }, - ]); - const all = await getJson(`/api/v1/sessions/${id}/messages?page_size=100`); - const idsDesc = all.body.data.items.map((m) => m.id); - expect(idsDesc).toHaveLength(3); - - const first = await getJson(`/api/v1/sessions/${id}/messages?page_size=1`); - expect(first.body.data.items.map((m) => m.id)).toEqual([idsDesc[0]]); - expect(first.body.data.has_more).toBe(true); - - const older = await getJson( - `/api/v1/sessions/${id}/messages?before_id=${idsDesc[0]}`, - ); - expect(older.body.data.items.map((m) => m.id)).toEqual([idsDesc[1], idsDesc[2]]); - expect(older.body.data.has_more).toBe(false); - - const newer = await getJson( - `/api/v1/sessions/${id}/messages?after_id=${idsDesc[2]}`, - ); - expect(newer.body.data.items.map((m) => m.id)).toEqual([idsDesc[0], idsDesc[1]]); - expect(newer.body.data.has_more).toBe(false); - }); - - it('filters the page by role after pagination', async () => { - const id = await createSession(); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'q' }], toolCalls: [] }, - { role: 'assistant', content: [{ type: 'text', text: 'a' }], toolCalls: [] }, - { role: 'user', content: [{ type: 'text', text: 'q2' }], toolCalls: [] }, - ]); - const { body } = await getJson(`/api/v1/sessions/${id}/messages?role=user`); - expect(body.code).toBe(0); - expect(body.data.items.every((m) => m.role === 'user')).toBe(true); - expect(body.data.items).toHaveLength(2); - expect(body.data.items.every((m) => MSG_ID.test(m.id))).toBe(true); - }); - - it('reads the persisted full transcript for a cold session', async () => { - const id = await createSession(); - const session = getLiveSessionById(server!.core.accessor, id); - if (session === undefined) throw new Error(`session ${id} not found`); - await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); - const agent = session.accessor.get(IAgentLifecycleService).handleOf('main')!; - const ctx = agent.accessor.get(IAgentContextMemoryService); - ctx.append( - { role: 'user', content: [{ type: 'text', text: 'm0' }], toolCalls: [] }, - { role: 'assistant', content: [{ type: 'text', text: 'm1' }], toolCalls: [] }, - { role: 'user', content: [{ type: 'text', text: 'm2' }], toolCalls: [] }, - ); - ctx.applyCompaction({ - summary: 'summary', - contextSummary: 'summary', - compactedCount: 3, - tokensBefore: 100, - }); - await agent.accessor.get(IWireService).flush(); - - const livePage = await getJson(`/api/v1/sessions/${id}/messages?page_size=100`); - expect(livePage.body.data.items).toHaveLength(4); - const liveSummaryId = livePage.body.data.items[0]!.id; - - await server!.close(); - server = undefined; - await boot(); - - const { body } = await getJson(`/api/v1/sessions/${id}/messages?page_size=100`); - expect(body.code).toBe(0); - expect(body.data.items).toHaveLength(4); - expect(body.data.items.every((m) => MSG_ID.test(m.id))).toBe(true); - - const [summary, _m2, maybeM1] = body.data.items; - if (maybeM1 === undefined) throw new Error('expected m1 message'); - const m1 = maybeM1; - expect(summary!.id).toBe(liveSummaryId); - expect(summary).toMatchObject({ - role: 'user', - metadata: { origin: { kind: 'compaction_summary' } }, - }); - - const got = await getJson(`/api/v1/sessions/${id}/messages/${m1.id}`); - expect(got.body.code).toBe(0); - expect(got.body.data).toMatchObject({ - id: m1.id, - role: 'assistant', - content: [{ type: 'text', text: 'm1' }], - }); - - const missing = await getJson(`/api/v1/sessions/${id}/messages/msg_does_not_exist`); - expect(missing.body.code).toBe(40403); - }); -}); diff --git a/packages/kap-server/test/plugins.test.ts b/packages/kap-server/test/plugins.test.ts index 7961ed15eb4..072bb26286e 100644 --- a/packages/kap-server/test/plugins.test.ts +++ b/packages/kap-server/test/plugins.test.ts @@ -217,8 +217,8 @@ describe('server-v2 /api/v1 plugins', () => { expect(badSource.body.code).toBe(40001); }); - it('fans out event.plugin.changed over WS on install and remove', async () => { - const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/ws`, [ + it('fans out plugin messages over WS on install and remove', async () => { + const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v3/ws`, [ `kimi-code.bearer.${bearerToken(server!)}`, ]); const types: string[] = []; @@ -237,12 +237,12 @@ describe('server-v2 /api/v1 plugins', () => { const source = await makePluginDir('demo-plugin', '1.0.0'); await call('POST', '/api/v1/plugins', { source }); await vi.waitFor(() => { - expect(types).toContain('event.plugin.changed'); + expect(types).toContain('plugin'); }); await call('POST', '/api/v1/plugins/demo-plugin:remove'); await vi.waitFor(() => { - expect(types.filter((t) => t === 'event.plugin.changed').length).toBeGreaterThanOrEqual(2); + expect(types.filter((t) => t === 'plugin').length).toBeGreaterThanOrEqual(2); }); } finally { ws.close(); diff --git a/packages/kap-server/test/protocolMessages.test.ts b/packages/kap-server/test/protocolMessages.test.ts index 5906bc4c73a..ddd954d20bf 100644 --- a/packages/kap-server/test/protocolMessages.test.ts +++ b/packages/kap-server/test/protocolMessages.test.ts @@ -81,7 +81,6 @@ const sessionInfo = { }, permission_rules: [], message_count: 0, - last_seq: 0, }; const turn = { diff --git a/packages/kap-server/test/search/searchRoute.test.ts b/packages/kap-server/test/search/searchRoute.test.ts index 7b7292c2709..99e2cdb6000 100644 --- a/packages/kap-server/test/search/searchRoute.test.ts +++ b/packages/kap-server/test/search/searchRoute.test.ts @@ -274,8 +274,8 @@ describe('server-v2 session routes with the global search DB unavailable', () => expect(coldList.data.items.map((item) => item.id)).toContain(id); const got = await getJson<{ id: string }>(`/api/v1/sessions/${id}`); expect(got.code).toBe(0); - const messages = await getJson<{ items: unknown[] }>(`/api/v1/sessions/${id}/messages`); - expect(messages.code).toBe(0); + const history = await getJson<{ messages: unknown[] }>(`/api/v1/sessions/${id}/history`); + expect(history.code).toBe(0); const probe = await stat(join(home as string, 'search-index')); expect(probe.isFile()).toBe(true); diff --git a/packages/kap-server/test/search/searchService.test.ts b/packages/kap-server/test/search/searchService.test.ts index 2c0bccc15e9..8173687956d 100644 --- a/packages/kap-server/test/search/searchService.test.ts +++ b/packages/kap-server/test/search/searchService.test.ts @@ -14,7 +14,6 @@ import type { } from '@moonshot-ai/agent-core-v2'; import { DATABASE_SECTION } from '@moonshot-ai/agent-core-v2'; import { MiniDb } from '@moonshot-ai/minidb'; -import { TranscriptStore, type TranscriptOperation } from '@moonshot-ai/transcript'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SearchIndexCore, type SyncSessionInput } from '../../src/search/indexCore'; @@ -24,6 +23,8 @@ import { InlineSearchBackend, drainGlobalSearchDisposals, type LiveTranscriptSource, + type LiveTranscriptView, + type LiveWireDoc, type SearchBackend, } from '../../src/search/searchService'; import { @@ -1634,11 +1635,11 @@ describe('GlobalSearchService', () => { } function fakeLiveSource( - stores: Map, + views: Map, calls?: LiveSourceCalls, ): LiveTranscriptSource { return { - forSessionLive: (sessionId) => stores.get(sessionId), + forSessionLive: (sessionId) => views.get(sessionId), whenReady: async (sessionId) => { calls?.whenReady.push(sessionId); }, @@ -1648,137 +1649,61 @@ describe('GlobalSearchService', () => { }; } - function makeLiveStore(sessionId: string): TranscriptStore { - const store = new TranscriptStore(sessionId); - store.ensureAgent('main', { agentId: 'main', type: 'main' }); - store.getAgent('main')!.apply([ - { - op: 'turn.upsert', - turn: { - kind: 'turn', - turnId: 't0', - ordinal: 0, - state: 'completed', - origin: { kind: 'user' }, - prompt: '帮我看看苹果怎么挑', - startedAt: new Date(T1).toISOString(), - }, - }, - { - op: 'step.upsert', - turnId: 't0', - step: { - kind: 'step', - stepId: 't0.1', - turnId: 't0', - ordinal: 1, - state: 'completed', - startedAt: new Date(T2).toISOString(), - }, - }, - { - op: 'frame.upsert', - turnId: 't0', - stepId: 't0.1', - frame: { kind: 'thinking', frameId: 't0.1.f1', text: '苹果 thinking 不可见' }, - }, - { - op: 'frame.upsert', - turnId: 't0', - stepId: 't0.1', - frame: { - kind: 'tool', - frameId: 't0.1.f2', - toolCallId: 'call-1', - name: 'Read', - state: 'done', - }, - }, - { - op: 'frame.upsert', - turnId: 't0', - stepId: 't0.1', - frame: { - kind: 'text', - frameId: 't0.1.f3', - role: 'assistant', - text: '苹果要挑红富士。', - }, - }, - ]); - return store; + function makeView(docs: Map): LiveTranscriptView { + return { + agents: () => [...docs.keys()].map((agentId) => ({ agentId })), + docs: (agentId) => docs.get(agentId), + }; + } + + function makeLiveView(): LiveTranscriptView { + return makeView( + new Map([ + [ + 'main', + [ + { role: 'user', text: '帮我看看苹果怎么挑', time: T1, turn: 0 }, + { role: 'assistant', text: '苹果要挑红富士。', time: T2, turn: 0, stepId: 't0.1' }, + ], + ], + ]), + ); } function addLiveTurn( - store: TranscriptStore, + docs: Map, agentId: string, turn: { ordinal: number; startedAt: number; prompt?: string; - state?: 'running' | 'completed'; steps?: readonly { stepId: string; startedAt?: number; endedAt?: number; - state?: 'running' | 'completed'; texts?: readonly string[]; }[]; }, ): void { - const turnId = `t${turn.ordinal}`; - const ops: TranscriptOperation[] = [ - { - op: 'turn.upsert', - turn: { - kind: 'turn', - turnId, - ordinal: turn.ordinal, - state: turn.state ?? 'completed', - origin: { kind: 'user' }, - prompt: turn.prompt, - startedAt: new Date(turn.startedAt).toISOString(), - }, - }, - ]; + const list = docs.get(agentId) ?? []; + docs.set(agentId, list); + if (turn.prompt !== undefined) { + list.push({ role: 'user', text: turn.prompt, time: turn.startedAt, turn: turn.ordinal }); + } for (const step of turn.steps ?? []) { - ops.push({ - op: 'step.upsert', - turnId, - step: { - kind: 'step', - stepId: step.stepId, - turnId, - ordinal: Number(step.stepId.split('.')[1] ?? 0), - state: step.state ?? 'completed', - startedAt: - step.startedAt !== undefined ? new Date(step.startedAt).toISOString() : undefined, - endedAt: step.endedAt !== undefined ? new Date(step.endedAt).toISOString() : undefined, - }, - }); - (step.texts ?? []).forEach((text, i) => { - ops.push({ - op: 'frame.upsert', - turnId, - stepId: step.stepId, - frame: { - kind: 'text', - frameId: `${step.stepId}.f${i}`, - role: 'assistant', - text, - }, - }); - }); + const time = step.endedAt ?? step.startedAt ?? turn.startedAt; + for (const text of step.texts ?? []) { + list.push({ role: 'assistant', text, time, turn: turn.ordinal, stepId: step.stepId }); + } } - store.getAgent(agentId)!.apply(ops); } it('serves container-scoped literal queries from the live transcript store', async () => { const s1 = summary('s1', '苹果标题', T1); - const stores = new Map([['s1', makeLiveStore('s1')]]); + const views = new Map([['s1', makeLiveView()]]); const calls: LiveSourceCalls = { whenReady: [], ensureAgentHistory: [] }; const service = track(makeService(home!, gettableIndex([s1]))); - service.setLiveTranscriptSource(fakeLiveSource(stores, calls)); + service.setLiveTranscriptSource(fakeLiveSource(views, calls)); const page = await service.search({ query: '苹果', @@ -1827,7 +1752,7 @@ describe('GlobalSearchService', () => { it('accepts single-character literal queries on the live route', async () => { const s1 = summary('s1', '苹果标题', T1); const service = track(makeService(home!, gettableIndex([s1]))); - service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', makeLiveStore('s1')]]))); + service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', makeLiveView()]]))); const page = await service.search({ query: '苹', @@ -1869,12 +1794,11 @@ describe('GlobalSearchService', () => { it('serves terms queries from the live store and orders hits by tf score', async () => { const s1 = summary('s1', '无关标题', T1); - const store = new TranscriptStore('s1'); - store.ensureAgent('main', { agentId: 'main', type: 'main' }); - addLiveTurn(store, 'main', { ordinal: 0, startedAt: T1, prompt: '苹果怎么挑' }); - addLiveTurn(store, 'main', { ordinal: 1, startedAt: T2, prompt: '苹果苹果都要' }); + const docs = new Map(); + addLiveTurn(docs, 'main', { ordinal: 0, startedAt: T1, prompt: '苹果怎么挑' }); + addLiveTurn(docs, 'main', { ordinal: 1, startedAt: T2, prompt: '苹果苹果都要' }); const service = track(makeService(home!, gettableIndex([s1]))); - service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', store]]))); + service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', makeView(docs)]]))); const page = await service.search({ query: '苹果', container: { sessionId: 's1' } }); expect(page.source).toBe('live'); @@ -1895,17 +1819,17 @@ describe('GlobalSearchService', () => { stepBeginLine('u1', 1, T1 + 100), assistantStepLine('苹果要挑红富士。', 'u1', T2), ]); - const stores = new Map([['s1', makeLiveStore('s1')]]); + const views = new Map([['s1', makeLiveView()]]); const service = track(makeService(home!, gettableIndex([s1]))); await service.reindex(); - service.setLiveTranscriptSource(fakeLiveSource(stores)); + service.setLiveTranscriptSource(fakeLiveSource(views)); const query = { query: '苹果', container: { sessionId: 's1' } }; const live = await service.search(query); expect(live.source).toBe('live'); expect(live.items.length).toBe(2); - stores.delete('s1'); + views.delete('s1'); const index = await service.search(query); expect(index.source).toBe('index'); expect(index.items.length).toBe(2); @@ -1931,18 +1855,16 @@ describe('GlobalSearchService', () => { it('applies role, time, agent and sort filters on the live route', async () => { const s1 = summary('s1', '', T1); - const store = new TranscriptStore('s1'); - store.ensureAgent('main', { agentId: 'main', type: 'main' }); - store.ensureAgent('sub', { agentId: 'sub', type: 'sub' }); - addLiveTurn(store, 'main', { + const docs = new Map([['sub', []]]); + addLiveTurn(docs, 'main', { ordinal: 0, startedAt: T1, prompt: '苹果 user question', steps: [{ stepId: 't0.1', startedAt: T2, texts: ['苹果 assistant answer'] }], }); - addLiveTurn(store, 'sub', { ordinal: 0, startedAt: T3, prompt: '苹果 subagent prompt' }); + addLiveTurn(docs, 'sub', { ordinal: 0, startedAt: T3, prompt: '苹果 subagent prompt' }); const service = track(makeService(home!, gettableIndex([s1]))); - service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', store]]))); + service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', makeView(docs)]]))); const base = { query: '苹果', container: { sessionId: 's1' } }; const users = await service.search({ ...base, role: 'user' }); @@ -1972,11 +1894,10 @@ describe('GlobalSearchService', () => { it('hits the session title doc on the live route (terms mode)', async () => { const s1 = summary('s1', '苹果标题', T1); - const store = new TranscriptStore('s1'); - store.ensureAgent('main', { agentId: 'main', type: 'main' }); - addLiveTurn(store, 'main', { ordinal: 0, startedAt: T1, prompt: '随便聊聊' }); + const docs = new Map(); + addLiveTurn(docs, 'main', { ordinal: 0, startedAt: T1, prompt: '随便聊聊' }); const service = track(makeService(home!, gettableIndex([s1]))); - service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', store]]))); + service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', makeView(docs)]]))); const page = await service.search({ query: '苹果', container: { sessionId: 's1' } }); expect(page.source).toBe('live'); @@ -1990,14 +1911,12 @@ describe('GlobalSearchService', () => { it('scopes container.agentId queries to that agent only', async () => { const s1 = summary('s1', '', T1); - const store = new TranscriptStore('s1'); - store.ensureAgent('main', { agentId: 'main', type: 'main' }); - store.ensureAgent('sub', { agentId: 'sub', type: 'sub' }); - addLiveTurn(store, 'main', { ordinal: 0, startedAt: T1, prompt: '苹果 from main' }); - addLiveTurn(store, 'sub', { ordinal: 0, startedAt: T2, prompt: '苹果 from sub' }); + const docs = new Map(); + addLiveTurn(docs, 'main', { ordinal: 0, startedAt: T1, prompt: '苹果 from main' }); + addLiveTurn(docs, 'sub', { ordinal: 0, startedAt: T2, prompt: '苹果 from sub' }); const calls: LiveSourceCalls = { whenReady: [], ensureAgentHistory: [] }; const service = track(makeService(home!, gettableIndex([s1]))); - service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', store]]), calls)); + service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', makeView(docs)]]), calls)); const page = await service.search({ query: '苹果', @@ -2015,8 +1934,8 @@ describe('GlobalSearchService', () => { const s1 = summary('s1', '', T1); const calls: LiveSourceCalls = { whenReady: [], ensureAgentHistory: [] }; const service = track(makeService(home!, gettableIndex([s1]))); - const stores = new Map([['s1', new TranscriptStore('s1')]]); - service.setLiveTranscriptSource(fakeLiveSource(stores, calls)); + const views = new Map([['s1', makeView(new Map())]]); + service.setLiveTranscriptSource(fakeLiveSource(views, calls)); const empty = await service.search({ query: '苹果', container: { sessionId: 's1' } }); expect(empty.source).toBe('live'); @@ -2025,15 +1944,14 @@ describe('GlobalSearchService', () => { expect(calls.whenReady).toEqual(['s1']); expect(calls.ensureAgentHistory).toEqual([]); - const store = new TranscriptStore('s1'); - store.ensureAgent('main', { agentId: 'main', type: 'main' }); - addLiveTurn(store, 'main', { + const docs = new Map(); + addLiveTurn(docs, 'main', { ordinal: 0, startedAt: T1, steps: [{ stepId: 't0.1', startedAt: T1, texts: ['', ' '] }], }); - addLiveTurn(store, 'main', { ordinal: 1, startedAt: T2, prompt: '苹果 survives' }); - stores.set('s1', store); + addLiveTurn(docs, 'main', { ordinal: 1, startedAt: T2, prompt: '苹果 survives' }); + views.set('s1', makeView(docs)); const page = await service.search({ query: '苹果', container: { sessionId: 's1' } }); expect(page.items.length).toBe(1); expect(page.items[0]!.role).toBe('user'); @@ -2046,9 +1964,9 @@ describe('GlobalSearchService', () => { const service = track(makeService(home!, gettableIndex([s1]))); await service.reindex(); - const store = makeLiveStore('s1'); + const view = makeLiveView(); service.setLiveTranscriptSource({ - forSessionLive: (sessionId) => (sessionId === 's1' ? store : undefined), + forSessionLive: (sessionId) => (sessionId === 's1' ? view : undefined), whenReady: async () => { throw new Error('backfill boom'); }, @@ -2062,17 +1980,15 @@ describe('GlobalSearchService', () => { it('searches the partial text of an in-flight turn', async () => { const s1 = summary('s1', '', T1); - const store = new TranscriptStore('s1'); - store.ensureAgent('main', { agentId: 'main', type: 'main' }); - addLiveTurn(store, 'main', { + const docs = new Map(); + addLiveTurn(docs, 'main', { ordinal: 0, startedAt: T1, - state: 'running', prompt: '苹果 running prompt', - steps: [{ stepId: 't0.1', startedAt: T2, state: 'running', texts: ['苹果 partial answer'] }], + steps: [{ stepId: 't0.1', startedAt: T2, texts: ['苹果 partial answer'] }], }); const service = track(makeService(home!, gettableIndex([s1]))); - service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', store]]))); + service.setLiveTranscriptSource(fakeLiveSource(new Map([['s1', makeView(docs)]]))); const page = await service.search({ query: '苹果', container: { sessionId: 's1' } }); expect(page.source).toBe('live'); @@ -2085,9 +2001,9 @@ describe('GlobalSearchService', () => { it('matches nothing for a query that tokenizes to zero terms', async () => { const s1 = summary('s1', '', T1); - const stores = new Map([['s1', makeLiveStore('s1')]]); + const views = new Map([['s1', makeLiveView()]]); const service = track(makeService(home!, gettableIndex([s1]))); - service.setLiveTranscriptSource(fakeLiveSource(stores)); + service.setLiveTranscriptSource(fakeLiveSource(views)); const page = await service.search({ query: '+++', container: { sessionId: 's1' } }); expect(page.source).toBe('live'); @@ -2101,10 +2017,10 @@ describe('GlobalSearchService', () => { userLine('苹果 index one', T1), assistantLine('苹果 index two', T2), ]); - const stores = new Map([['s1', makeLiveStore('s1')]]); + const views = new Map([['s1', makeLiveView()]]); const service = track(makeService(home!, gettableIndex([s1]))); await service.reindex(); - service.setLiveTranscriptSource(fakeLiveSource(stores)); + service.setLiveTranscriptSource(fakeLiveSource(views)); const query = { query: '苹果', @@ -2116,7 +2032,7 @@ describe('GlobalSearchService', () => { expect(livePage.source).toBe('live'); expect(livePage.hasMore).toBe(true); - stores.delete('s1'); + views.delete('s1'); await expect( service.search({ ...query, pageToken: livePage.pageToken }), ).rejects.toMatchObject({ reason: 'invalid_page_token' }); @@ -2124,7 +2040,7 @@ describe('GlobalSearchService', () => { const indexPage = await service.search(query); expect(indexPage.source).toBe('index'); expect(indexPage.hasMore).toBe(true); - stores.set('s1', makeLiveStore('s1')); + views.set('s1', makeLiveView()); await expect( service.search({ ...query, pageToken: indexPage.pageToken }), ).rejects.toMatchObject({ reason: 'invalid_page_token' }); @@ -2137,16 +2053,16 @@ describe('GlobalSearchService', () => { stepBeginLine('u1', 1, T1 + 100), assistantStepLine('苹果要挑红富士。', 'u1', T2), ]); - const stores = new Map([['s1', makeLiveStore('s1')]]); + const views = new Map([['s1', makeLiveView()]]); const service = track(makeService(home!, gettableIndex([s1]))); await service.reindex(); - service.setLiveTranscriptSource(fakeLiveSource(stores)); + service.setLiveTranscriptSource(fakeLiveSource(views)); const query = { query: '苹果', mode: 'literal' as const, container: { sessionId: 's1' } }; const live = await service.search(query); expect(live.source).toBe('live'); - stores.delete('s1'); + views.delete('s1'); const index = await service.search(query); expect(index.source).toBe('index'); diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts deleted file mode 100644 index 3d69189891e..00000000000 --- a/packages/kap-server/test/services/transcript.test.ts +++ /dev/null @@ -1,3988 +0,0 @@ -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { execFile } from 'node:child_process'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; - -import { - INTERACTION_TAG_SESSION_ID, - IAgentLifecycleService, - IAgentLoopService, - IAgentPromptService, - IAgentScopeContext, - IAgentTaskService, - IEventBus, - IFlagService, - ISessionIndex, - ISessionMetadata, - ISessionLifecycleService, - ISessionManager, - IWorkspaceInstanceManager, - LifecycleScope, - interactions, - makeAgentScopeContext, - type AgentContext, - type Event2, - TOWER_FLAG_ID, - _setTowerFeatureAssembledForTests, - type ISessionScopeHandle, - type Scope, -} from '@moonshot-ai/agent-core-v2'; - -import { TowerStore } from '@moonshot-ai/agent-core-v2/features/tower/protocol/index'; -import { - AgentTranscript, - TranscriptStore, - type AgentTranscriptSnapshot, - type AppendOp, - type FrameUpsertOp, - type InteractionUpsertOp, - type TranscriptFrame, - type TranscriptOperation, - type TranscriptTask, - type TranscriptTurn, -} from '@moonshot-ai/transcript'; -import { afterEach, describe, expect, it } from 'vitest'; - -import { bindSessionTranscript } from '../../src/services/transcript/coreBinding'; -import { toWireQuestion } from '../../src/protocol/question-wire'; -import type { AgentActivitySnapshot } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; -import type { LegacyActivityApproval } from '../../src/services/legacyStatus/legacyStatus'; -import { - AgentTranscriptProjector, - type ProjectorBusEvent, -} from '../../src/services/transcript/coreEventMap'; -import { - healTurnOps, - TranscriptService, - snapshotToOps, - TRANSCRIPT_OPS_JOURNAL_CAPACITY, -} from '../../src/services/transcript/transcriptService'; - -_setTowerFeatureAssembledForTests(true); - -const execFileAsync = promisify(execFile); - -function ev(payload: Record): ProjectorBusEvent { - return payload as unknown as ProjectorBusEvent; -} - -const TEST_SESSION_ID = 'session-test'; - -function turnOps(turnId: string, items: ReturnType): TranscriptTurn { - const turn = items.find( - (item): item is TranscriptTurn => item.kind === 'turn' && item.turnId === turnId, - ); - if (turn === undefined) throw new Error(`turn ${turnId} not found`); - return turn; -} - -function coldTranscriptService(home: string): TranscriptService { - return new TranscriptService({ - homeDir: home, - core: { - accessor: { - get: (token: unknown) => { - if (token === ISessionManager) return { get: () => undefined, list: () => [] }; - if (token === IWorkspaceInstanceManager) { - return { list: () => [], onDidChange: () => ({ dispose: () => undefined }) }; - } - if (token === ISessionIndex) return { get: async () => ({ workspaceId: 'ws' }) }; - return undefined; - }, - }, - } as unknown as Scope, - }); -} - -describe('AgentTranscriptProjector', () => { - it('projects a full turn: headers, delta appends, flush, tool frames', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const ops: TranscriptOperation[] = []; - const feed = (event: ProjectorBusEvent): void => { - const mapped = projector.map(event); - ops.push(...mapped); - tx.apply(mapped); - }; - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1, stepId: 'u1' })); - feed(ev({ type: 'assistant.delta', turnId: 1, delta: 'Hello' })); - feed(ev({ type: 'assistant.delta', turnId: 1, delta: ' world' })); - feed( - ev({ - type: 'tool.call.started', - turnId: 1, - toolCallId: 'call_1', - name: 'Bash', - args: '{"command":"ls"}', - display: { kind: 'command', command: 'ls' }, - }), - ); - feed(ev({ type: 'tool.result', turnId: 1, toolCallId: 'call_1', output: 'file.txt' })); - feed(ev({ type: 'turn.step.completed', turnId: 1, step: 1, stepId: 'u1' })); - feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - - const appends = ops.filter((op): op is AppendOp => op.op === 'append'); - expect(appends.map((op) => [op.offset, op.text])).toEqual([ - [0, 'Hello'], - [5, ' world'], - ]); - const upserts = ops.filter((op): op is FrameUpsertOp => op.op === 'frame.upsert'); - const flushUpsert = upserts.find( - (op) => op.frame.kind === 'text' && op.frame.text === 'Hello world', - ); - expect(flushUpsert).toBeDefined(); - - const turn = turnOps('t1', tx.getItems()); - expect(turn.state).toBe('completed'); - expect(turn.origin).toEqual({ kind: 'user', payload: { kind: 'user' } }); - expect(turn.endedAt).toBeTypeOf('string'); - expect(turn.steps).toHaveLength(1); - const step = turn.steps[0]!; - expect(step.state).toBe('completed'); - const text = step.frames.find((frame) => frame.kind === 'text'); - expect(text).toMatchObject({ role: 'assistant', text: 'Hello world' }); - const tool = step.frames.find((frame) => frame.kind === 'tool'); - expect(tool).toMatchObject({ - frameId: 't1.1.call_1', - toolCallId: 'call_1', - name: 'Bash', - state: 'done', - input: { command: 'ls' }, - output: 'file.txt', - display: { kind: 'command', command: 'ls' }, - }); - }); - - it('projects the live prompt from turn.started and keeps it through turn.ended', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => { - tx.apply(projector.map(event)); - }; - - feed(ev({ - type: 'turn.started', - turnId: 0, - promptId: 'prompt-1', - origin: { kind: 'user' }, - prompt: 'fix the bug', - })); - feed(ev({ type: 'assistant.delta', turnId: 0, delta: 'on it' })); - feed(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); - - const turn = turnOps('t0', tx.getItems()); - expect(turn.triggerPromptId).toBe('prompt-1'); - expect(turn.prompt).toBe('fix the bug'); - expect(turn.state).toBe('completed'); - }); - - it('projects the live prompt for subagent system triggers and keeps it through turn.ended', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => { - tx.apply(projector.map(event)); - }; - - feed( - ev({ - type: 'turn.started', - turnId: 0, - origin: { kind: 'system_trigger', name: 'subagent' }, - prompt: 'scan the repo', - promptAttachments: [{ kind: 'image', fileId: 'file_1' }], - }), - ); - feed(ev({ type: 'assistant.delta', turnId: 0, delta: 'scanning' })); - feed(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); - - const turn = turnOps('t0', tx.getItems()); - expect(turn.prompt).toBe('scan the repo'); - expect(turn.attachmentIds).toEqual(['t0.att1']); - expect(turn.state).toBe('completed'); - }); - - it('projects turn.started promptAttachments into attachment entities and turn.attachmentIds', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const ops: TranscriptOperation[] = []; - const feed = (event: ProjectorBusEvent): void => { - const mapped = projector.map(event); - ops.push(...mapped); - tx.apply(mapped); - }; - - feed( - ev({ - type: 'turn.started', - turnId: 0, - origin: { kind: 'user' }, - prompt: 'what is this?', - promptAttachments: [{ kind: 'image', fileId: 'file_1', name: 'photo.png' }], - }), - ); - feed(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); - - expect(ops.filter((op) => op.op === 'attachment.upsert')).toEqual([ - { - op: 'attachment.upsert', - attachment: { - attachmentId: 't0.att1', - mediaType: 'image/*', - name: 'photo.png', - source: { kind: 'session_media', fileId: 'file_1' }, - }, - }, - ]); - - const turn = turnOps('t0', tx.getItems()); - expect(turn.prompt).toBe('what is this?'); - expect(turn.attachmentIds).toEqual(['t0.att1']); - expect(tx.getAttachment('t0.att1')).toEqual({ - attachmentId: 't0.att1', - mediaType: 'image/*', - name: 'photo.png', - source: { kind: 'session_media', fileId: 'file_1' }, - }); - }); - - it('projects turn.started file promptAttachments into path-sourced attachment entities', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const ops: TranscriptOperation[] = []; - const feed = (event: ProjectorBusEvent): void => { - const mapped = projector.map(event); - ops.push(...mapped); - tx.apply(mapped); - }; - - feed( - ev({ - type: 'turn.started', - turnId: 0, - origin: { kind: 'user' }, - prompt: 'summarize this', - promptAttachments: [ - { - kind: 'file', - name: 'report.pdf', - mediaType: 'application/pdf', - size: 1234, - path: '/data/report.pdf', - }, - ], - }), - ); - feed(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); - - expect(ops.filter((op) => op.op === 'attachment.upsert')).toEqual([ - { - op: 'attachment.upsert', - attachment: { - attachmentId: 't0.att1', - mediaType: 'application/pdf', - name: 'report.pdf', - size: 1234, - }, - }, - ]); - - const turn = turnOps('t0', tx.getItems()); - expect(turn.prompt).toBe('summarize this'); - expect(turn.attachmentIds).toEqual(['t0.att1']); - expect(tx.getAttachment('t0.att1')).toEqual({ - attachmentId: 't0.att1', - mediaType: 'application/pdf', - name: 'report.pdf', - size: 1234, - }); - }); - - it('places late-attach deltas into the engine-reported active step', () => { - const tx = new AgentTranscript('main'); - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { - stepOrdinal: (turnId) => (turnId === 't0' ? 2 : undefined), - }); - - const ops = projector.map(ev({ type: 'assistant.delta', turnId: 0, delta: 'late' })); - tx.apply(ops); - - const turn = turnOps('t0', tx.getItems()); - expect(turn.steps.map((s) => s.stepId)).toEqual(['t0.2']); - expect(turn.steps[0]?.frames[0]).toMatchObject({ kind: 'text', text: 'late' }); - }); - - it('adopts a backfilled stream frame on mid-turn attach instead of clobbering it', () => { - const tx = new AgentTranscript('main'); - tx.apply([ - { - op: 'turn.upsert', - turn: { kind: 'turn', turnId: 't0', ordinal: 0, state: 'running', origin: { kind: 'user' } }, - }, - { - op: 'step.upsert', - turnId: 't0', - step: { kind: 'step', stepId: 't0.1', turnId: 't0', ordinal: 1, state: 'running' }, - }, - { - op: 'frame.upsert', - turnId: 't0', - stepId: 't0.1', - frame: { kind: 'text', frameId: 't0.1.f1', role: 'assistant', text: 'Hello ' }, - }, - ]); - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { - stepFrames: (turnId, stepId) => - tx.getTurn(turnId)?.steps.find((s) => s.stepId === stepId)?.frames, - }); - - const ops = projector.map(ev({ type: 'assistant.delta', turnId: 0, delta: 'world' })); - tx.apply(ops); - expect(ops.some((op) => op.op === 'frame.upsert')).toBe(false); - const append = ops.find((op): op is AppendOp => op.op === 'append'); - expect(append && [append.offset, append.text]).toEqual([6, 'world']); - const turn = turnOps('t0', tx.getItems()); - const text = turn.steps[0]?.frames.find((frame) => frame.kind === 'text'); - expect(text).toMatchObject({ text: 'Hello world' }); - - const next = projector.map(ev({ type: 'thinking.delta', turnId: 0, delta: 'hmm' })); - const created = next.find((op): op is FrameUpsertOp => op.op === 'frame.upsert'); - expect(created?.frame.frameId).toBe('t0.1.f2'); - }); - - it('adopts a backfilled tool frame when the result arrives after a mid-bind attach', () => { - const tx = new AgentTranscript('main'); - tx.apply([ - { - op: 'turn.upsert', - turn: { kind: 'turn', turnId: 't0', ordinal: 0, state: 'running', origin: { kind: 'user' } }, - }, - { - op: 'step.upsert', - turnId: 't0', - step: { kind: 'step', stepId: 't0.1', turnId: 't0', ordinal: 1, state: 'running' }, - }, - { - op: 'frame.upsert', - turnId: 't0', - stepId: 't0.1', - frame: { - kind: 'tool', - frameId: 't0.1.call_1', - toolCallId: 'call_1', - name: 'Bash', - state: 'running', - input: { command: 'ls' }, - }, - }, - ]); - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { - toolFrame: (toolCallId) => { - for (const item of tx.getItems()) { - if (item.kind !== 'turn') continue; - for (const step of item.steps) { - for (const frame of step.frames) { - if (frame.kind === 'tool' && frame.toolCallId === toolCallId) { - return { turnId: item.turnId, stepId: step.stepId, frame }; - } - } - } - } - return undefined; - }, - }); - - const ops = projector.map(ev({ type: 'tool.result', toolCallId: 'call_1', output: 'file.txt' })); - expect(ops).toHaveLength(1); - tx.apply(ops); - const turn = turnOps('t0', tx.getItems()); - const tool = turn.steps[0]?.frames.find((frame) => frame.kind === 'tool'); - expect(tool).toMatchObject({ toolCallId: 'call_1', state: 'done', output: 'file.txt' }); - }); - - it('adopts a seeded parent tool frame when subagent.spawned links the child', () => { - const tx = new AgentTranscript('main'); - tx.apply([ - { - op: 'turn.upsert', - turn: { kind: 'turn', turnId: 't0', ordinal: 0, state: 'running', origin: { kind: 'user' } }, - }, - { - op: 'step.upsert', - turnId: 't0', - step: { kind: 'step', stepId: 't0.1', turnId: 't0', ordinal: 1, state: 'running' }, - }, - { - op: 'frame.upsert', - turnId: 't0', - stepId: 't0.1', - frame: { - kind: 'tool', - frameId: 't0.1.call_agent', - toolCallId: 'call_agent', - name: 'Agent', - state: 'running', - input: { prompt: 'scan' }, - }, - }, - ]); - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { - toolFrame: (toolCallId) => { - for (const item of tx.getItems()) { - if (item.kind !== 'turn') continue; - for (const step of item.steps) { - for (const frame of step.frames) { - if (frame.kind === 'tool' && frame.toolCallId === toolCallId) { - return { turnId: item.turnId, stepId: step.stepId, frame }; - } - } - } - } - return undefined; - }, - }); - - const ops = projector.map( - ev({ - type: 'subagent.spawned', - subagentId: 'agent-1', - subagentName: 'explore', - parentToolCallId: 'call_agent', - runInBackground: false, - }), - ); - tx.apply(ops); - const turn = turnOps('t0', tx.getItems()); - const tool = turn.steps[0]?.frames.find((frame) => frame.kind === 'tool'); - expect(tool?.kind === 'tool' && tool.agentRefs).toEqual([{ agentId: 'agent-1', role: 'child' }]); - }); - - it('gives live markers their own namespace so they never collide with backfilled markers', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - tx.apply([{ op: 'marker.upsert', item: { kind: 'marker', markerId: 'm1', marker: 'skill' } }]); - - const ops = projector.map(ev({ type: 'compaction.started', trigger: 'auto' })); - tx.apply(ops); - - const markers = tx - .getItems() - .filter((item): item is Extract => item.kind === 'marker'); - expect(markers.map((m) => [m.markerId, m.marker])).toEqual([ - ['m1', 'skill'], - ['live-m1', 'compaction'], - ]); - }); - - it('snapshotToOps anchors standalone items so backfill keeps history order against live turns', () => { - const snapshot: AgentTranscriptSnapshot = { - interactions: [], - attachments: [], - todos: [], - prompts: [], - items: [ - { - kind: 'turn', - turnId: 't0', - ordinal: 0, - state: 'completed', - origin: { kind: 'user' }, - prompt: 'one', - steps: [], - }, - { kind: 'marker', markerId: 'm1', marker: 'skill' }, - { - kind: 'turn', - turnId: 't1', - ordinal: 1, - state: 'completed', - origin: { kind: 'user' }, - prompt: 'two', - steps: [], - }, - { kind: 'taskref', refId: 'r1', taskId: 'bash-1' }, - ], - tasks: [], - meta: {}, - }; - const ops = snapshotToOps(snapshot); - expect(ops.find((op) => op.op === 'marker.upsert')).toMatchObject({ beforeTurn: 1 }); - expect(ops.find((op) => op.op === 'taskref.upsert')).toMatchObject({ beforeTurn: 2 }); - - const tx = new AgentTranscript('main'); - tx.apply([ - { - op: 'turn.upsert', - turn: { kind: 'turn', turnId: 't2', ordinal: 2, state: 'running', origin: { kind: 'user' } }, - }, - ]); - tx.apply(ops); - expect( - tx.getItems().map((item) => { - if (item.kind === 'turn') return item.turnId; - if (item.kind === 'marker') return item.markerId; - return item.refId; - }), - ).toEqual(['t0', 'm1', 't1', 'r1', 't2']); - }); - - it('snapshotToOps flattens attachment entities so backfilled attachmentIds never dangle', () => { - const snapshot: AgentTranscriptSnapshot = { - interactions: [], - attachments: [ - { - attachmentId: 'att_1', - mediaType: 'image/*', - name: 'shot.png', - source: { kind: 'file', fileId: 'file_1' }, - }, - ], - todos: [], - prompts: [], - items: [ - { - kind: 'turn', - turnId: 't0', - ordinal: 0, - state: 'completed', - origin: { kind: 'user' }, - prompt: 'what is this?', - attachmentIds: ['att_1'], - steps: [], - }, - ], - tasks: [], - meta: {}, - }; - - const ops = snapshotToOps(snapshot); - expect(ops.filter((op) => op.op === 'attachment.upsert')).toEqual([ - { op: 'attachment.upsert', attachment: snapshot.attachments[0] }, - ]); - - const tx = new AgentTranscript('main'); - tx.apply(ops); - expect(tx.getAttachment('att_1')).toEqual(snapshot.attachments[0]); - expect(turnOps('t0', tx.getItems()).attachmentIds).toEqual(['att_1']); - }); - - it('flushes open frames on turn.ended even without step completion', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - feed(ev({ type: 'thinking.delta', turnId: 1, delta: 'hmm' })); - feed(ev({ type: 'assistant.delta', turnId: 1, delta: 'partial' })); - feed(ev({ type: 'turn.ended', turnId: 1, reason: 'cancelled' })); - - const turn = turnOps('t1', tx.getItems()); - expect(turn.state).toBe('cancelled'); - const step = turn.steps[0]!; - expect(step.state).toBe('interrupted'); - expect(step.frames).toContainEqual( - expect.objectContaining({ kind: 'thinking', text: 'hmm' }), - ); - expect(step.frames).toContainEqual( - expect.objectContaining({ kind: 'text', text: 'partial' }), - ); - }); - - it('marks a user-cancelled turn with an interruption marker, but not programmatic aborts', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' }, prompt: 'hi' })); - feed( - ev({ type: 'turn.ended', turnId: 0, reason: 'cancelled', interruptReason: 'user_cancelled' }), - ); - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'again' })); - feed(ev({ type: 'turn.ended', turnId: 1, reason: 'cancelled', interruptReason: 'aborted' })); - feed(ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' }, prompt: 'legacy' })); - feed(ev({ type: 'turn.ended', turnId: 2, reason: 'cancelled' })); - - const markers = tx - .getItems() - .filter((item): item is Extract => item.kind === 'marker'); - expect(markers).toHaveLength(1); - expect(markers[0]).toMatchObject({ - marker: 'interruption', - payload: { turnId: 0, reason: 'user_cancelled' }, - }); - }); - - it('carries usage / finishReason / the full timing breakdown on turn.step.completed', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - feed( - ev({ - type: 'turn.step.completed', - turnId: 1, - step: 1, - usage: { inputOther: 100, output: 20, inputCacheRead: 30, inputCacheCreation: 40 }, - rawFinishReason: 'tool_calls', - llmFirstTokenLatencyMs: 120, - llmStreamDurationMs: 900, - llmRequestBuildMs: 10, - llmServerFirstTokenMs: 110, - llmServerDecodeMs: 800, - llmClientConsumeMs: 100, - llmClientBlockedMs: 40, - }), - ); - - const step = turnOps('t1', tx.getItems()).steps[0]!; - expect(step.state).toBe('completed'); - expect(step.usage).toEqual({ - inputOther: 100, - output: 20, - inputCacheRead: 30, - inputCacheCreation: 40, - }); - expect(step.finishReason).toBe('tool_calls'); - expect(step.timing).toEqual({ - llmFirstTokenLatencyMs: 120, - llmStreamDurationMs: 900, - llmRequestBuildMs: 10, - llmServerFirstTokenMs: 110, - llmServerDecodeMs: 800, - llmClientConsumeMs: 100, - llmClientBlockedMs: 40, - }); - - feed(ev({ type: 'turn.step.started', turnId: 1, step: 2 })); - feed( - ev({ - type: 'turn.step.completed', - turnId: 1, - step: 2, - finishReason: 'stop', - rawFinishReason: 'raw_stop', - providerFinishReason: 'provider_stop', - }), - ); - expect(turnOps('t1', tx.getItems()).steps[1]!.finishReason).toBe('stop'); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 3 })); - feed( - ev({ type: 'turn.step.completed', turnId: 1, step: 3, providerFinishReason: 'length' }), - ); - expect(turnOps('t1', tx.getItems()).steps[2]!.finishReason).toBe('length'); - }); - - it('carries endReason / endMessage on turn.step.interrupted', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - feed( - ev({ - type: 'turn.step.interrupted', - turnId: 1, - step: 1, - reason: 'aborted', - message: 'user cancelled', - }), - ); - - const step = turnOps('t1', tx.getItems()).steps[0]!; - expect(step.state).toBe('interrupted'); - expect(step.endReason).toBe('aborted'); - expect(step.endMessage).toBe('user cancelled'); - }); - - it('sets retry on turn.step.retrying and clears it at the terminal upsert', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - const step = (): TranscriptTurn['steps'][number] => turnOps('t1', tx.getItems()).steps[0]!; - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - feed( - ev({ - type: 'turn.step.retrying', - turnId: 1, - step: 1, - failedAttempt: 1, - nextAttempt: 2, - maxAttempts: 3, - delayMs: 2000, - errorName: 'ProviderRateLimitError', - errorMessage: '429 too many requests', - statusCode: 429, - }), - ); - - expect(step().state).toBe('running'); - expect(step().retry).toEqual({ - failedAttempt: 1, - nextAttempt: 2, - maxAttempts: 3, - delayMs: 2000, - errorName: 'ProviderRateLimitError', - errorMessage: '429 too many requests', - statusCode: 429, - }); - - feed(ev({ type: 'turn.step.completed', turnId: 1, step: 1 })); - expect(step().state).toBe('completed'); - expect(step().retry).toBeUndefined(); - }); - - it('fills durationMs / error / accumulated step usage on turn.ended', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - feed( - ev({ - type: 'turn.step.completed', - turnId: 1, - step: 1, - usage: { inputOther: 100, output: 10, inputCacheRead: 5, inputCacheCreation: 50 }, - }), - ); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 2 })); - feed( - ev({ - type: 'turn.step.completed', - turnId: 1, - step: 2, - usage: { inputOther: 200, output: 20, inputCacheRead: 0, inputCacheCreation: 25 }, - }), - ); - feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 4200 })); - - const turn = turnOps('t1', tx.getItems()); - expect(turn.durationMs).toBe(4200); - expect(turn.usage).toEqual({ inputTokens: 375, cachedTokens: 5, outputTokens: 30 }); - - feed(ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } })); - feed( - ev({ - type: 'turn.ended', - turnId: 2, - reason: 'failed', - durationMs: 50, - error: { code: 'internal', message: 'kaboom', retryable: false }, - }), - ); - const failed = turnOps('t2', tx.getItems()); - expect(failed.state).toBe('failed'); - expect(failed.error).toBe('kaboom'); - expect(failed.usage).toBeUndefined(); - }); - - it('takes the turn header endedAt from the turn.ended event time', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed', time: 1_700_000_000_000 })); - expect(turnOps('t1', tx.getItems()).endedAt).toBe(new Date(1_700_000_000_000).toISOString()); - - feed(ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.ended', turnId: 2, reason: 'completed' })); - expect(turnOps('t2', tx.getItems()).endedAt).toBeTypeOf('string'); - }); - - it('accumulates tool.call.delta into inputText, kept across tool.call.started', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - const toolFrame = (toolCallId: string): TranscriptFrame | undefined => - turnOps('t1', tx.getItems()) - .steps.flatMap((step) => step.frames) - .find((frame) => frame.kind === 'tool' && frame.toolCallId === toolCallId); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - feed( - ev({ - type: 'tool.call.delta', - turnId: 1, - toolCallId: 'c1', - name: 'Bash', - argumentsPart: '{"comm', - }), - ); - feed(ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 'c1', argumentsPart: 'and":"ls"}' })); - expect(toolFrame('c1')).toMatchObject({ - kind: 'tool', - frameId: 't1.1.c1', - name: 'Bash', - state: 'running', - inputText: '{"command":"ls"}', - }); - feed(ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 'c2', argumentsPart: '{}' })); - expect(toolFrame('c2')).toMatchObject({ name: '', inputText: '{}' }); - - feed( - ev({ - type: 'tool.call.started', - turnId: 1, - toolCallId: 'c1', - name: 'Bash', - args: { command: 'ls' }, - }), - ); - expect(toolFrame('c1')).toMatchObject({ - input: { command: 'ls' }, - inputText: '{"command":"ls"}', - }); - feed(ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 'c1', argumentsPart: '\n' })); - expect(toolFrame('c1')).toMatchObject({ inputText: '{"command":"ls"}\n' }); - feed(ev({ type: 'tool.result', turnId: 1, toolCallId: 'c1', output: 'file.txt' })); - expect(toolFrame('c1')).toMatchObject({ state: 'done', inputText: '{"command":"ls"}\n' }); - }); - - it('overwrites tool frame progress and drops progress for unknown calls', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - expect( - projector.map( - ev({ - type: 'tool.progress', - turnId: 1, - toolCallId: 'ghost', - update: { kind: 'stdout', text: 'x' }, - }), - ), - ).toEqual([]); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - feed( - ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'c1', name: 'Bash', args: {} }), - ); - feed( - ev({ - type: 'tool.progress', - turnId: 1, - toolCallId: 'c1', - update: { kind: 'stdout', text: 'line1' }, - }), - ); - const tool = (): TranscriptFrame | undefined => - turnOps('t1', tx.getItems()).steps[0]!.frames.find((frame) => frame.kind === 'tool'); - expect(tool()).toMatchObject({ progress: { kind: 'stdout', text: 'line1' } }); - - feed( - ev({ - type: 'tool.progress', - turnId: 1, - toolCallId: 'c1', - update: { kind: 'progress', percent: 40 }, - }), - ); - expect(tool()).toMatchObject({ progress: { kind: 'progress', percent: 40 } }); - expect((tool() as { progress?: Record }).progress?.['text']).toBeUndefined(); - }); - - it('marks tool.result errors and keeps the display payload', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - feed( - ev({ - type: 'tool.call.started', - turnId: 1, - toolCallId: 'c1', - name: 'Read', - args: { path: '/x' }, - display: { kind: 'file', path: '/x' }, - }), - ); - feed(ev({ type: 'tool.result', turnId: 1, toolCallId: 'c1', output: 'ENOENT', isError: true })); - - const tool = turnOps('t1', tx.getItems()).steps[0]!.frames.find((f) => f.kind === 'tool'); - expect(tool).toMatchObject({ - state: 'error', - output: 'ENOENT', - error: 'ENOENT', - display: { kind: 'file', path: '/x' }, - }); - }); - - it('projects process tasks as shell tasks with streaming output', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const ops: TranscriptOperation[] = []; - const feed = (event: ProjectorBusEvent): void => { - const mapped = projector.map(event); - ops.push(...mapped); - tx.apply(mapped); - }; - - const started = { - taskId: 'bash-1', - kind: 'process', - description: 'ls -la', - status: 'running', - detached: false, - startedAt: 1_700_000_000_000, - endedAt: null, - }; - feed(ev({ type: 'task.started', info: started })); - feed(ev({ type: 'shell.started', commandId: 'cmd-1', taskId: 'bash-1' })); - feed(ev({ type: 'shell.output', commandId: 'cmd-1', update: { kind: 'stdout', text: 'a\n' } })); - feed(ev({ type: 'shell.output', commandId: 'cmd-1', update: { kind: 'stderr', text: 'b\n' } })); - feed( - ev({ - type: 'task.terminated', - info: { ...started, status: 'completed', endedAt: 1_700_000_001_000 }, - }), - ); - - expect(ops.some((op) => op.op === 'taskref.upsert' && op.item.taskId === 'bash-1')).toBe(true); - const appends = ops.filter((op): op is AppendOp => op.op === 'append'); - expect(appends.map((op) => [op.offset, op.text])).toEqual([ - [0, 'a\n'], - [2, 'b\n'], - ]); - - const task = tx.getTask('bash-1'); - expect(task).toMatchObject({ - kind: 'shell', - state: 'completed', - detached: false, - description: 'ls -la', - outputTail: 'a\nb\n', - }); - expect( - projector.map( - ev({ type: 'shell.output', commandId: 'cmd-1', update: { kind: 'progress', percent: 50 } }), - ), - ).toEqual([]); - }); - - it('fills the shell task output from late stderr chunks before completing', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - - tx.apply(projector.map(ev({ type: 'shell.started', commandId: 'c1', taskId: 'task-1' }))); - tx.apply( - projector.map(ev({ type: 'shell.output', commandId: 'c1', update: { kind: 'stderr', text: 'boom' } })), - ); - tx.apply(projector.map(ev({ type: 'shell.completed', commandId: 'c1', isError: true }))); - - expect(tx.getTask('task-1')).toMatchObject({ state: 'failed', outputTail: 'boom' }); - }); - - it('routes shell output/completion via the event taskId when shell.started was missed', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - - tx.apply( - projector.map( - ev({ type: 'shell.output', commandId: 'c1', taskId: 'task-1', update: { kind: 'stdout', text: 'hello' } }), - ), - ); - expect(tx.getTask('task-1')).toMatchObject({ kind: 'shell', state: 'running', outputTail: 'hello' }); - expect(tx.getItems()).toContainEqual(expect.objectContaining({ kind: 'taskref', taskId: 'task-1' })); - - tx.apply(projector.map(ev({ type: 'shell.completed', commandId: 'c1', taskId: 'task-1', isError: false }))); - expect(tx.getTask('task-1')).toMatchObject({ state: 'completed', outputTail: 'hello' }); - }); - - it('emits a taskref when only shell.completed arrives for a command', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - - tx.apply(projector.map(ev({ type: 'shell.completed', commandId: 'c1', taskId: 'task-1', isError: true }))); - - expect(tx.getTask('task-1')).toMatchObject({ kind: 'shell', state: 'failed' }); - expect(tx.getItems()).toContainEqual(expect.objectContaining({ kind: 'taskref', taskId: 'task-1' })); - }); - - it('projects no-taskId shell failures under a synthetic per-command task id', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - - tx.apply( - projector.map(ev({ type: 'shell.output', commandId: 'c1', update: { kind: 'stderr', text: 'boom' } })), - ); - expect(tx.getTask('shell-c1')).toMatchObject({ kind: 'shell', state: 'running', outputTail: 'boom' }); - - tx.apply(projector.map(ev({ type: 'shell.completed', commandId: 'c1', isError: true }))); - expect(tx.getTask('shell-c1')).toMatchObject({ state: 'failed', outputTail: 'boom' }); - expect(tx.getItems()).toContainEqual(expect.objectContaining({ kind: 'taskref', taskId: 'shell-c1' })); - }); - - it('marks a foreground shell task terminal on shell.completed', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - - tx.apply(projector.map(ev({ type: 'shell.started', commandId: 'c1', taskId: 'task-1' }))); - expect(tx.getTask('task-1')?.state).toBe('running'); - - tx.apply(projector.map(ev({ type: 'shell.completed', commandId: 'c1', isError: false }))); - expect(tx.getTask('task-1')).toMatchObject({ kind: 'shell', state: 'completed' }); - expect(tx.getTask('task-1')?.endedAt).toBeTypeOf('string'); - - tx.apply(projector.map(ev({ type: 'shell.started', commandId: 'c2', taskId: 'task-2' }))); - tx.apply(projector.map(ev({ type: 'shell.completed', commandId: 'c2', isError: true }))); - expect(tx.getTask('task-2')?.state).toBe('failed'); - }); - - it('ignores task.notified (it re-surfaces as an origin:task turn)', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - expect(projector.map(ev({ type: 'task.notified', taskId: 't' }))).toEqual([]); - }); - - it('links spawned subagents to the spawning tool frame (member for swarm)', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - feed( - ev({ - type: 'tool.call.started', - turnId: 1, - toolCallId: 'call_swarm', - name: 'AgentSwarm', - args: {}, - }), - ); - feed( - ev({ - type: 'subagent.spawned', - subagentId: 'agent-0', - subagentName: 'worker', - parentToolCallId: 'call_swarm', - description: 'scan the repo', - swarmIndex: 0, - runInBackground: false, - model: 'example-model', - thinkingEffort: 'high', - }), - ); - feed(ev({ type: 'subagent.completed', subagentId: 'agent-0', resultSummary: 'done' })); - - const tool = turnOps('t1', tx.getItems()).steps[0]!.frames.find((f) => f.kind === 'tool'); - expect(tool).toMatchObject({ - agentRefs: [{ agentId: 'agent-0', role: 'member' }], - }); - const task = tx.getTask('agent-0'); - expect(task).toMatchObject({ - kind: 'subagent', - state: 'completed', - agentId: 'agent-0', - description: 'scan the repo', - detached: false, - model: 'example-model', - thinkingEffort: 'high', - }); - }); - - it('keys an Agent-tool subagent row by its registered task id and folds the lifecycle', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed( - ev({ - type: 'subagent.spawned', - subagentId: 'agent-1', - subagentName: 'explore', - parentToolCallId: 'call-1', - description: 'Inspect files', - runInBackground: true, - taskId: 'task-9', - }), - ); - feed( - ev({ - type: 'task.started', - info: { - taskId: 'task-9', - kind: 'agent', - description: 'Inspect files', - status: 'running', - detached: true, - agentId: 'agent-1', - startedAt: 1_700_000_000_000, - endedAt: null, - }, - }), - ); - feed(ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' })); - feed( - ev({ - type: 'task.terminated', - info: { - taskId: 'task-9', - kind: 'agent', - description: 'Inspect files', - status: 'completed', - detached: true, - agentId: 'agent-1', - startedAt: 1_700_000_000_000, - endedAt: 1_700_000_001_000, - }, - }), - ); - - expect(tx.getTask('task-9')).toMatchObject({ - kind: 'subagent', - state: 'completed', - agentId: 'agent-1', - description: 'Inspect files', - detached: true, - resultSummary: 'done', - }); - expect(tx.getTask('agent-1')).toBeUndefined(); - }); - - it('drops the stale task mapping when a child respawns without a task id', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed( - ev({ - type: 'subagent.spawned', - subagentId: 'agent-1', - subagentName: 'explore', - parentToolCallId: 'call-1', - description: 'Inspect files', - runInBackground: true, - taskId: 'task-9', - }), - ); - feed(ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' })); - feed( - ev({ - type: 'subagent.spawned', - subagentId: 'agent-1', - subagentName: 'worker', - parentToolCallId: 'call-2', - description: 'scan again', - runInBackground: false, - }), - ); - feed(ev({ type: 'subagent.started', subagentId: 'agent-1' })); - - expect(tx.getTask('task-9')).toMatchObject({ state: 'completed', resultSummary: 'done' }); - expect(tx.getTask('agent-1')).toMatchObject({ kind: 'subagent', state: 'running' }); - }); - - it('recovers the agent → task association from a backfilled task.started', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed( - ev({ - type: 'task.started', - info: { - taskId: 'task-9', - kind: 'agent', - description: 'Inspect files', - status: 'running', - detached: true, - agentId: 'agent-1', - startedAt: 1_700_000_000_000, - endedAt: null, - }, - }), - ); - feed(ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' })); - - expect(tx.getTask('task-9')).toMatchObject({ state: 'completed', resultSummary: 'done' }); - expect(tx.getTask('agent-1')).toBeUndefined(); - }); - - it('projects goal updates into meta.goal plus an inline marker', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const snapshot = { - goalId: 'g1', - objective: 'ship it', - status: 'active', - completionCriterion: 'tests green', - turnsUsed: 3, - tokensUsed: 1234, - wallClockMs: 5000, - budget: { tokenBudget: 50000 }, - }; - const ops = projector.map(ev({ type: 'goal.updated', snapshot, change: { kind: 'lifecycle' } })); - tx.apply(ops); - - expect(tx.getMeta().goal).toEqual({ - objective: 'ship it', - status: 'active', - completionCriterion: 'tests green', - budgetUsed: 1234, - budgetLimit: 50000, - }); - const marker = tx.getItems().find((item) => item.kind === 'marker'); - expect(marker).toMatchObject({ marker: 'goal', payload: { snapshot } }); - - const clearedOps = projector.map(ev({ type: 'goal.updated', snapshot: null })); - expect(clearedOps[0]).toEqual({ op: 'meta.merge', meta: { goal: null } }); - tx.apply(clearedOps); - expect(tx.getMeta().goal).toBeUndefined(); - }); - - it('mirrors plan / swarm mode slices into meta.modes (only when provided)', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - - tx.apply(projector.map(ev({ type: 'agent.status.updated', planMode: true }))); - tx.apply(projector.map(ev({ type: 'agent.status.updated', swarmMode: true }))); - expect(tx.getMeta().modes).toEqual({ plan: {}, swarm: {} }); - - tx.apply(projector.map(ev({ type: 'agent.status.updated', planMode: false }))); - expect(tx.getMeta().modes).toEqual({ swarm: {} }); - tx.apply(projector.map(ev({ type: 'agent.status.updated', swarmMode: false }))); - expect(tx.getMeta().modes).toBeUndefined(); - }); - - it('mirrors the tower mode slice into meta.modes', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - - tx.apply(projector.map(ev({ type: 'agent.status.updated', towerMode: true }))); - expect(tx.getMeta().modes).toEqual({ tower: {} }); - - tx.apply(projector.map(ev({ type: 'agent.status.updated', towerMode: false }))); - expect(tx.getMeta().modes).toBeUndefined(); - }); - - it('mirrors status slices into meta.agent (shallow-merged across slices)', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - const usageOnly = projector.map(ev({ type: 'agent.status.updated', usage: {} })); - expect(usageOnly).toEqual([{ op: 'meta.merge', meta: { agent: { usage: {} } } }]); - - feed(ev({ type: 'agent.status.updated', model: 'k2', thinkingEffort: 'high' })); - feed( - ev({ - type: 'agent.status.updated', - usage: { - total: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }, - }, - }), - ); - feed( - ev({ - type: 'agent.status.updated', - contextTokens: 1000, - maxContextTokens: 200000, - contextUsage: 0.5, - }), - ); - feed(ev({ type: 'agent.status.updated', permission: 'yolo' })); - - expect(tx.getMeta().agent).toEqual({ - model: 'k2', - thinkingEffort: 'high', - usage: { total: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 } }, - contextTokens: 1000, - maxContextTokens: 200000, - contextUsage: 0.5, - permission: 'yolo', - }); - - feed(ev({ type: 'agent.status.updated', model: 'k3' })); - expect(tx.getMeta().agent).toMatchObject({ model: 'k3', thinkingEffort: 'high' }); - }); - - it('maps domain events into meta.agent.phase', () => { - let snapshot: AgentActivitySnapshot = {}; - let approvals: readonly LegacyActivityApproval[] = []; - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { - activitySnapshot: () => snapshot, - pendingApprovals: () => approvals, - }); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - const runningTurn = (overrides: Record): AgentActivitySnapshot => ({ - turn: { - turnId: 1, - phase: 'running', - step: 1, - ending: false, - activeToolCalls: [], - since: 1000, - ...overrides, - }, - }); - - snapshot = runningTurn({}); - feed(ev({ type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } })); - expect(tx.getMeta().agent?.phase).toEqual({ - kind: 'running', - turnId: 1, - step: 1, - stepId: '', - since: 1000, - }); - - snapshot = runningTurn({ - phase: 'retrying', - retry: { failedAttempt: 1, nextAttempt: 2, maxAttempts: 10, delayMs: 500 }, - }); - feed( - ev({ - type: 'turn.step.retrying', - agentId: 'main', - turnId: 1, - step: 1, - failedAttempt: 1, - nextAttempt: 2, - maxAttempts: 10, - delayMs: 500, - errorName: 'status', - errorMessage: 'boom', - }), - ); - expect(tx.getMeta().agent?.phase).toMatchObject({ - kind: 'retrying', - failedAttempt: 1, - nextAttempt: 2, - maxAttempts: 10, - }); - - approvals = [{ approvalId: 'ap1', toolCallId: 'c1', since: 1500 }]; - feed( - ev({ - type: 'permission.approval.requested', - agentId: 'main', - turnId: 1, - toolCallId: 'c1', - id: 'ap1', - }), - ); - expect(tx.getMeta().agent?.phase).toEqual({ - kind: 'awaiting_approval', - turnId: 1, - step: 1, - approval: { approvalId: 'ap1', toolCallId: 'c1' }, - since: 1500, - }); - - feed(ev({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed', durationMs: 100 })); - expect(tx.getMeta().agent?.phase).toMatchObject({ - kind: 'ended', - turnId: 1, - reason: 'completed', - durationMs: 100, - }); - - snapshot = {}; - feed(ev({ type: 'permission.approval.resolved', agentId: 'main', turnId: 1, toolCallId: 'c1', id: 'ap1', decision: 'approved' })); - expect(tx.getMeta().agent?.phase).toMatchObject({ kind: 'ended', turnId: 1 }); - }); - - it('projects plan.revision as a marker and refines the active plan badge', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { - resolvePlanRevisionKey: (key) => `sessions/w/s/agents/main/${key}`, - }); - const tx = new AgentTranscript('main'); - - const revision = { - type: 'plan.revision', - id: 'plan-1', - version: 1, - key: 'plan/plan-1/v1.md', - sha256: 'deadbeef', - bytes: 128, - }; - - tx.apply(projector.map(ev(revision))); - expect(tx.getMeta().modes).toBeUndefined(); - - tx.apply(projector.map(ev({ type: 'agent.status.updated', planMode: true }))); - expect(tx.getMeta().modes).toEqual({ plan: {} }); - tx.apply( - projector.map(ev({ ...revision, version: 2, key: 'plan/plan-1/v2.md' })), - ); - expect(tx.getMeta().modes).toEqual({ - plan: { reviewPath: 'sessions/w/s/agents/main/plan/plan-1/v2.md', version: 2 }, - }); - - const markers = tx - .getItems() - .filter((item) => item.kind === 'marker' && item.marker === 'plan.revision'); - expect(markers.map((item) => item.kind === 'marker' && item.markerId)).toEqual([ - 'live-m1', - 'live-m2', - ]); - expect(markers[1]).toMatchObject({ - payload: { - id: 'plan-1', - version: 2, - path: 'sessions/w/s/agents/main/plan/plan-1/v2.md', - sha256: 'deadbeef', - bytes: 128, - }, - }); - - tx.apply(projector.map(ev({ type: 'agent.status.updated', planMode: false }))); - expect(tx.getMeta().modes).toBeUndefined(); - expect( - tx.getItems().filter((item) => item.kind === 'marker' && item.marker === 'plan.revision'), - ).toHaveLength(2); - }); - - it('projects skill / plugin-command / cron / compaction / hook / undo markers', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'skill.activated', activationId: 'a1', skillName: 'gen-docs', trigger: 'user-slash' })); - feed( - ev({ - type: 'plugin_command.activated', - activationId: 'a2', - pluginId: 'p', - commandName: 'c', - trigger: 'user-slash', - }), - ); - feed(ev({ type: 'cron.fired', origin: { kind: 'cron_job', jobId: 'j1' }, prompt: 'ping' })); - feed(ev({ type: 'compaction.started', trigger: 'auto' })); - feed(ev({ type: 'compaction.completed', result: { kept: 3 } })); - feed(ev({ type: 'hook.result', hookEvent: 'SessionStart', content: 'hook says hi' })); - feed( - ev({ - type: 'hook.result', - turnId: 3, - hookEvent: 'UserPromptSubmit', - content: 'blocked by hook', - blocked: true, - }), - ); - feed(ev({ type: 'context.spliced', start: 1, deleteCount: 2, messages: [] })); - - const markers = tx - .getItems() - .filter((item): item is Extract => item.kind === 'marker'); - expect(markers.map((m) => m.marker)).toEqual([ - 'skill', - 'skill', - 'cron.fired', - 'compaction', - 'compaction', - 'hook', - 'hook', - 'undo', - ]); - expect(markers[1]!.payload).toMatchObject({ variant: 'plugin_command' }); - expect(markers[3]!.payload).toMatchObject({ phase: 'started' }); - expect(markers[4]!.payload).toMatchObject({ phase: 'completed' }); - expect(markers[5]!.payload).toEqual({ hookEvent: 'SessionStart', content: 'hook says hi' }); - expect(markers[6]!.payload).toEqual({ - turnId: 3, - hookEvent: 'UserPromptSubmit', - content: 'blocked by hook', - blocked: true, - }); - expect(markers[7]!.payload).toMatchObject({ start: 1, deleteCount: 2 }); - }); - - it('removes trailing turns and the undo marker on context.undone', () => { - const tx = new AgentTranscript('main'); - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { - items: () => tx.getItems(), - }); - const feed = (event: ProjectorBusEvent): void => { - tx.apply(projector.map(event)); - }; - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'first' })); - feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - feed(ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' }, prompt: 'second' })); - feed(ev({ type: 'turn.ended', turnId: 2, reason: 'completed' })); - feed(ev({ type: 'context.spliced', start: 1, deleteCount: 2, messages: [] })); - - const removeOps = projector.map(ev({ type: 'context.undone', agentId: 'main', turns: 1 })); - expect(removeOps).toEqual([{ op: 'items.remove', ids: ['t2', 'live-m1'] }]); - tx.apply(removeOps); - - expect(tx.getItems().map((item) => item.kind)).toEqual(['turn']); - expect(turnOps('t1', tx.getItems()).prompt).toBe('first'); - }); - - it('removes multiple trailing turns and keeps taskrefs appended during them', () => { - const tx = new AgentTranscript('main'); - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { - items: () => tx.getItems(), - }); - const feed = (event: ProjectorBusEvent): void => { - tx.apply(projector.map(event)); - }; - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - feed(ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } })); - feed( - ev({ - type: 'task.started', - info: { - taskId: 'task1', - kind: 'process', - description: 'ls', - status: 'running', - startedAt: 1, - endedAt: null, - }, - }), - ); - feed(ev({ type: 'turn.ended', turnId: 2, reason: 'completed' })); - feed(ev({ type: 'turn.started', turnId: 3, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.ended', turnId: 3, reason: 'completed' })); - - const removeOps = projector.map(ev({ type: 'context.undone', agentId: 'main', turns: 2 })); - expect(removeOps).toEqual([{ op: 'items.remove', ids: ['t3', 't2'] }]); - tx.apply(removeOps); - - expect(tx.getItems().map((item) => item.kind)).toEqual(['turn', 'taskref']); - expect(tx.getTask('task1')?.state).toBe('running'); - }); - - it('ignores context.undone when no removable turns exist', () => { - const bare = new AgentTranscriptProjector('main', TEST_SESSION_ID); - expect(bare.map(ev({ type: 'context.undone', agentId: 'main', turns: 1 }))).toEqual([]); - - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { items: () => [] }); - expect(projector.map(ev({ type: 'context.undone', agentId: 'main', turns: 1 }))).toEqual([]); - }); - - it('removes every turn from fromTurnId onward, including trailing non-anchor turns', () => { - const tx = new AgentTranscript('main'); - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID, { - items: () => tx.getItems(), - }); - const feed = (event: ProjectorBusEvent): void => { - tx.apply(projector.map(event)); - }; - - feed(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' }, prompt: 'kept' })); - feed(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'undone' })); - feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - feed(ev({ type: 'turn.started', turnId: 2, origin: { kind: 'cron', taskId: 'j1' } })); - feed(ev({ type: 'turn.ended', turnId: 2, reason: 'completed' })); - feed(ev({ type: 'context.spliced', start: 1, deleteCount: 3, messages: [] })); - - const removeOps = projector.map( - ev({ type: 'context.undone', agentId: 'main', turns: 1, fromTurnId: 1 }), - ); - expect(removeOps).toEqual([{ op: 'items.remove', ids: ['t2', 't1', 'live-m1'] }]); - tx.apply(removeOps); - - expect(tx.getItems().map((item) => item.kind)).toEqual(['turn']); - expect(turnOps('t0', tx.getItems()).prompt).toBe('kept'); - }); - - it('projects error / warning events as notice markers outside any step', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - - tx.apply( - projector.map(ev({ type: 'error', code: 'mcp.failed', message: 'boom', retryable: false })), - ); - tx.apply(projector.map(ev({ type: 'warning', message: 'AGENTS.md oversized' }))); - - const markers = tx - .getItems() - .filter((item): item is Extract => item.kind === 'marker'); - expect(markers).toHaveLength(2); - expect(markers[0]).toMatchObject({ - marker: 'notice', - payload: { level: 'error', message: 'boom', event: { code: 'mcp.failed' } }, - }); - expect(markers[1]).toMatchObject({ - marker: 'notice', - payload: { level: 'warning', message: 'AGENTS.md oversized' }, - }); - }); - - it('emits interactions as global entities only (no inline frame), back-links on resolve', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 2, step: 1 })); - feed( - ev({ - type: 'tool.call.started', - turnId: 2, - toolCallId: 'call_9', - name: 'Bash', - args: {}, - }), - ); - - const request = { - toolCallId: 'call_9', - toolName: 'Bash', - action: 'run', - display: { kind: 'command', command: 'rm -rf /tmp/x' }, - }; - tx.apply( - projector.mapInteractionRequested({ - id: 'apr-1', - kind: 'approval', - payload: request, - createdAt: 1000, - }), - ); - - expect(turnOps('t2', tx.getItems()).steps[0]!.frames.map((f) => f.kind)).toEqual(['tool']); - expect(tx.getInteraction('apr-1')).toMatchObject({ - interactionId: 'apr-1', - interactionKind: 'approval', - toolCallId: 'call_9', - state: 'pending', - request, - }); - expect(tx.listPendingInteractions()).toEqual(['apr-1']); - - tx.apply(projector.mapInteractionResolved('apr-1', { decision: 'approved', scope: 'session' })); - - const tool = turnOps('t2', tx.getItems()).steps[0]!.frames.find((f) => f.kind === 'tool'); - expect(tool).toMatchObject({ approvalId: 'apr-1' }); - expect(turnOps('t2', tx.getItems()).steps[0]!.frames.map((f) => f.kind)).toEqual(['tool']); - expect(tx.getInteraction('apr-1')).toMatchObject({ - state: 'approved', - response: { decision: 'approved', scope: 'session' }, - }); - expect(tx.listPendingInteractions()).toEqual([]); - }); - - it('surfaces a mid-turn task notification as a user input frame linked to the task', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - const notified = (): ProjectorBusEvent => - ev({ - type: 'task.notified', - notificationType: 'task.completed', - title: 'Background process completed', - body: 'pnpm test — 42 passed', - severity: 'info', - sourceKind: 'background_task', - sourceId: 'task_1', - }); - - tx.apply(projector.map(notified())); - expect(tx.getItems()).toHaveLength(0); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - tx.apply(projector.map(notified())); - - const frames = turnOps('t1', tx.getItems()).steps[0]!.frames; - const frame = frames.find((f) => f.kind === 'text' && f.role === 'user'); - expect(frame).toMatchObject({ kind: 'text', role: 'user', taskId: 'task_1' }); - expect(frame?.kind === 'text' && frame.text).toContain('Background process completed'); - }); - - it('attaches a between-steps task notification to the following step', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - const notified = (sourceId: string): ProjectorBusEvent => - ev({ - type: 'task.notified', - notificationType: 'task.completed', - title: 'Background agent completed', - body: 'inspect done.', - severity: 'info', - sourceKind: 'background_task', - sourceId, - }); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - feed(ev({ type: 'turn.step.completed', turnId: 1, step: 1 })); - feed(notified('task_1')); - feed(notified('task_2')); - expect(turnOps('t1', tx.getItems()).steps[0]!.frames).toHaveLength(0); - - feed(ev({ type: 'turn.step.started', turnId: 1, step: 2 })); - const steps = turnOps('t1', tx.getItems()).steps; - expect(steps).toHaveLength(2); - expect(steps[1]!.frames.map((f) => f.kind === 'text' && f.taskId)).toEqual(['task_1', 'task_2']); - }); - - it('drops a task notification that is the turn prompt itself', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'task', taskId: 'task_1' } })); - feed( - ev({ - type: 'task.notified', - notificationType: 'task.completed', - title: 'Background agent completed', - body: 'inspect done.', - severity: 'info', - sourceKind: 'background_task', - sourceId: 'task_1', - }), - ); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - expect(turnOps('t1', tx.getItems()).steps[0]!.frames).toHaveLength(0); - }); - - it('keeps a different task’s notification in a task-origin turn', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'task', taskId: 'task_1' } })); - feed( - ev({ - type: 'task.notified', - notificationType: 'task.completed', - title: 'Background agent completed', - body: 'second task done.', - severity: 'info', - sourceKind: 'background_task', - sourceId: 'task_2', - }), - ); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - const frames = turnOps('t1', tx.getItems()).steps[0]!.frames; - expect(frames.map((f) => f.kind === 'text' && f.taskId)).toEqual(['task_2']); - }); - - it('drops a buffered task notification when the turn ends before the next step', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - feed(ev({ type: 'turn.step.completed', turnId: 1, step: 1 })); - feed( - ev({ - type: 'task.notified', - notificationType: 'task.completed', - title: 'Background agent completed', - body: 'inspect done.', - severity: 'info', - sourceKind: 'background_task', - sourceId: 'task_1', - }), - ); - feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - - feed(ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 2, step: 1 })); - expect(turnOps('t2', tx.getItems()).steps[0]!.frames).toHaveLength(0); - }); - - it('replaces the global todo document on a confirmed TodoList write', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - feed(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); - - feed(ev({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_read', name: 'TodoList', args: {} })); - feed(ev({ type: 'tool.result', toolCallId: 'call_read', output: '2 todos' })); - expect(tx.getTodo('todo')).toBeUndefined(); - - feed( - ev({ - type: 'tool.call.started', - turnId: 1, - toolCallId: 'call_write', - name: 'TodoList', - args: { todos: [{ title: 'write tests', status: 'in_progress' }, { title: 'ship', status: 'pending' }] }, - }), - ); - const writeFrame = turnOps('t1', tx.getItems()).steps[0]!.frames.find( - (f) => f.kind === 'tool' && f.toolCallId === 'call_write', - ); - expect(writeFrame?.kind === 'tool' && writeFrame.todoId).toBe('todo'); - - feed(ev({ type: 'tool.result', toolCallId: 'call_write', output: 'updated' })); - expect(tx.getTodo('todo')?.items).toEqual([ - { title: 'write tests', status: 'in_progress' }, - { title: 'ship', status: 'pending' }, - ]); - - feed( - ev({ - type: 'tool.call.started', - turnId: 1, - toolCallId: 'call_fail', - name: 'TodoList', - args: { todos: [] }, - }), - ); - feed(ev({ type: 'tool.result', toolCallId: 'call_fail', output: 'boom', isError: true })); - expect(tx.getTodo('todo')?.items).toHaveLength(2); - }); - - it('emits an unanchored entity when the payload has no toolCallId', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - - tx.apply( - projector.mapInteractionRequested({ - id: 'q1', - kind: 'question', - payload: { questions: [{ question: 'Pick', options: [] }] }, - createdAt: 1000, - }), - ); - expect(tx.getItems()).toHaveLength(0); - const entity = tx.getInteraction('q1'); - expect(entity).toMatchObject({ interactionKind: 'question', state: 'pending' }); - expect(entity?.toolCallId).toBeUndefined(); - expect(tx.listPendingInteractions()).toEqual(['q1']); - - tx.apply(projector.mapInteractionResolved('q1', null)); - expect(tx.getInteraction('q1')).toMatchObject({ state: 'dismissed' }); - expect(tx.listPendingInteractions()).toEqual([]); - }); - - it('projects question requests onto the wire shape with stable question/option ids', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - - tx.apply( - projector.mapInteractionRequested({ - id: 'q-wire', - kind: 'question', - payload: { - toolCallId: 'call_q', - turnId: 3, - questions: [ - { - question: 'Pick one', - header: 'h', - body: 'b', - multiSelect: false, - otherLabel: 'Other', - otherDescription: 'free text', - options: [{ label: 'A', description: 'first' }, { label: 'B' }], - }, - ], - }, - createdAt: 7000, - }), - ); - - const entity = tx.getInteraction('q-wire'); - expect(entity?.toolCallId).toBe('call_q'); - expect(entity?.request).toEqual({ - question_id: 'q-wire', - session_id: TEST_SESSION_ID, - questions: [ - { - id: 'q_0', - question: 'Pick one', - header: 'h', - body: 'b', - multi_select: false, - allow_other: true, - other_label: 'Other', - other_description: 'free text', - options: [ - { id: 'opt_0_0', label: 'A', description: 'first' }, - { id: 'opt_0_1', label: 'B' }, - ], - }, - ], - created_at: new Date(7000).toISOString(), - turn_id: 3, - tool_call_id: 'call_q', - }); - - tx.apply(projector.mapInteractionResolved('q-wire', { q_0: 'A' })); - expect(tx.getInteraction('q-wire')).toMatchObject({ state: 'answered' }); - }); - - it('keeps a malformed question payload raw instead of failing the projection', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - - tx.apply( - projector.mapInteractionRequested({ - id: 'q-raw', - kind: 'question', - payload: { toolCallId: 'call_x' }, - createdAt: 1000, - }), - ); - - const entity = tx.getInteraction('q-raw'); - expect(entity?.toolCallId).toBe('call_x'); - expect(entity?.request).toEqual({ toolCallId: 'call_x' }); - }); - - it('projects prompt submitted/completed/aborted/steered as global queue entities', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed( - ev({ - type: 'prompt.submitted', - promptId: 'p1', - userMessageId: 'm1', - status: 'running', - content: [{ type: 'text', text: 'first' }], - createdAt: '2026-01-01T00:00:00.000Z', - }), - ); - feed( - ev({ - type: 'prompt.submitted', - promptId: 'p2', - userMessageId: 'm2', - status: 'queued', - content: [{ type: 'text', text: 'second' }], - createdAt: '2026-01-01T00:00:01.000Z', - }), - ); - expect(tx.getPrompt('p1')).toMatchObject({ status: 'running', userMessageId: 'm1' }); - expect(tx.getPrompt('p2')).toMatchObject({ status: 'queued' }); - - feed(ev({ type: 'prompt.started', promptId: 'p2' })); - expect(tx.getPrompt('p2')).toMatchObject({ - status: 'running', - userMessageId: 'm2', - content: [{ type: 'text', text: 'second' }], - createdAt: '2026-01-01T00:00:01.000Z', - }); - - feed( - ev({ - type: 'prompt.steered', - activePromptId: 'p1', - promptIds: ['p2'], - content: [ - { type: 'text', text: 'first' }, - { type: 'text', text: 'second' }, - ], - steeredAt: '2026-01-01T00:00:02.000Z', - }), - ); - expect(tx.getPrompt('p1')).toMatchObject({ - status: 'running', - steeredAt: '2026-01-01T00:00:02.000Z', - content: [ - { type: 'text', text: 'first' }, - { type: 'text', text: 'second' }, - ], - }); - expect(tx.getPrompt('p2')).toMatchObject({ - status: 'completed', - userMessageId: 'm2', - steeredAt: '2026-01-01T00:00:02.000Z', - finishedAt: '2026-01-01T00:00:02.000Z', - }); - - feed( - ev({ - type: 'prompt.completed', - promptId: 'p1', - finishedAt: '2026-01-01T00:00:10.000Z', - reason: 'completed', - }), - ); - expect(tx.getPrompt('p1')).toMatchObject({ - status: 'completed', - finishedAt: '2026-01-01T00:00:10.000Z', - content: [ - { type: 'text', text: 'first' }, - { type: 'text', text: 'second' }, - ], - }); - - feed(ev({ type: 'prompt.aborted', promptId: 'p3', abortedAt: '2026-01-01T00:00:03.000Z' })); - expect(tx.getPrompt('p3')).toEqual({ - promptId: 'p3', - status: 'aborted', - createdAt: '2026-01-01T00:00:03.000Z', - finishedAt: '2026-01-01T00:00:03.000Z', - }); - feed( - ev({ - type: 'prompt.completed', - promptId: 'p4', - finishedAt: '2026-01-01T00:00:04.000Z', - reason: 'failed', - }), - ); - expect(tx.getPrompt('p4')).toEqual({ - promptId: 'p4', - status: 'failed', - createdAt: '2026-01-01T00:00:04.000Z', - finishedAt: '2026-01-01T00:00:04.000Z', - }); - }); - - it('projects prompt.steered media content to the wire shape (no daemon ref or path leak)', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed( - ev({ - type: 'prompt.steered', - activePromptId: 'p1', - promptIds: ['p2'], - content: [ - { type: 'text', text: 'look at this' }, - { - type: 'image_url', - imageUrl: { url: 'kimi-file://f_img1?path=%2Fabs%2Fsession%2Fmedia%2Ff_img1.png' }, - }, - ], - steeredAt: '2026-01-01T00:00:02.000Z', - }), - ); - - const prompt = tx.getPrompt('p1'); - expect(prompt?.content).toEqual([ - { type: 'text', text: 'look at this' }, - { type: 'image', source: { kind: 'session_media', file_id: 'f_img1' } }, - ]); - }); - - it('projects turn.steer as a user frame at the next step start, pairing promptIds from prompt.steered', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 3, origin: { kind: 'user' }, prompt: 'active' })); - feed(ev({ type: 'turn.step.started', turnId: 3, step: 1 })); - feed(ev({ type: 'turn.step.completed', turnId: 3, step: 1 })); - feed( - ev({ - type: 'prompt.steered', - activePromptId: 'p1', - promptIds: ['p2'], - content: [ - { type: 'text', text: 'steered in' }, - { type: 'video_url', videoUrl: { url: 'kimi-file://f_vid2', name: 'queued.mp4' } }, - ], - steeredAt: '2026-01-01T00:00:02.000Z', - }), - ); - feed( - ev({ - type: 'turn.steer', - input: [ - { type: 'text', text: 'steered in' }, - { type: 'video_url', videoUrl: { url: 'kimi-file://f_vid2', name: 'queued.mp4' } }, - ], - origin: { kind: 'user' }, - }), - ); - expect(turnOps('t3', tx.getItems()).steps).toHaveLength(1); - - feed(ev({ type: 'turn.step.started', turnId: 3, step: 2 })); - const turn = turnOps('t3', tx.getItems()); - expect(turn.steps).toHaveLength(2); - const frame = turn.steps[1]?.frames[0]; - expect(frame).toMatchObject({ - kind: 'text', - role: 'user', - text: 'steered in', - promptIds: ['p2'], - origin: { kind: 'user' }, - }); - expect(frame?.kind === 'text' ? frame.attachmentIds : undefined).toHaveLength(1); - const attachmentId = frame?.kind === 'text' ? frame.attachmentIds?.[0] : undefined; - expect(attachmentId === undefined ? undefined : tx.getAttachment(attachmentId)).toMatchObject({ - mediaType: 'video/*', - name: 'queued.mp4', - source: { kind: 'session_media', fileId: 'f_vid2' }, - }); - }); - - it('projects turn.steer into the running step immediately, with daemon media as attachments', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const ops: TranscriptOperation[] = []; - const feed = (event: ProjectorBusEvent): void => { - const mapped = projector.map(event); - ops.push(...mapped); - tx.apply(mapped); - }; - - feed(ev({ type: 'turn.started', turnId: 4, origin: { kind: 'user' }, prompt: 'active' })); - feed(ev({ type: 'turn.step.started', turnId: 4, step: 1 })); - feed( - ev({ - type: 'prompt.steered', - activePromptId: 'p1', - promptIds: ['p2', 'p3'], - content: [ - { type: 'text', text: 'look at this' }, - { - type: 'image_url', - imageUrl: { - url: 'kimi-file://f_img9?path=%2Fabs%2Fsession%2Fmedia%2Ff_img9.png', - name: 'architecture.png', - }, - }, - ], - steeredAt: '2026-01-01T00:00:02.000Z', - }), - ); - feed( - ev({ - type: 'turn.steer', - input: [ - { type: 'text', text: 'private instructions' }, - { type: 'text', text: 'private instructions' }, - { type: 'text', text: 'look at this' }, - { - type: 'image_url', - imageUrl: { - url: 'kimi-file://f_img9?path=%2Fabs%2Fsession%2Fmedia%2Ff_img9.png', - name: 'architecture.png', - }, - }, - ], - origin: { - kind: 'user', - skillActivations: [ - { activationId: 'a1', skillName: 'deploy', skillPath: '/private/deploy/SKILL.md' }, - { activationId: 'a2', skillName: 'review', skillArgs: 'strict', skillPath: '/private/review/SKILL.md' }, - ], - attachments: [{ - name: 'secret.txt', - mediaType: 'text/plain', - size: 12, - path: '/private/secret.txt', - }], - }, - }), - ); - - const attachmentOp = ops.find((op) => op.op === 'attachment.upsert'); - expect(attachmentOp).toMatchObject({ - attachment: { - mediaType: 'image/*', - name: 'architecture.png', - source: { kind: 'session_media', fileId: 'f_img9' }, - }, - }); - const frame = turnOps('t4', tx.getItems()).steps[0]?.frames[0]; - expect(frame).toMatchObject({ - kind: 'text', - role: 'user', - text: 'look at this', - promptIds: ['p2', 'p3'], - origin: { - kind: 'user', - skillActivations: [ - { skillName: 'deploy' }, - { skillName: 'review', skillArgs: 'strict' }, - ], - }, - }); - expect(JSON.stringify(frame)).not.toContain('/private/'); - expect(frame?.kind === 'text' ? frame.attachmentIds : undefined).toEqual([ - attachmentOp?.op === 'attachment.upsert' ? attachmentOp.attachment.attachmentId : undefined, - ]); - }); - - it('ignores turn.steer for non-user origins and for turns that are not running', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 5, origin: { kind: 'user' }, prompt: 'active' })); - feed(ev({ type: 'turn.step.started', turnId: 5, step: 1 })); - feed( - ev({ - type: 'turn.steer', - input: [{ type: 'text', text: 'backgrounded output' }], - origin: { kind: 'injection', variant: 'shell_command_backgrounded' }, - }), - ); - expect(turnOps('t5', tx.getItems()).steps[0]?.frames).toHaveLength(0); - - feed(ev({ type: 'turn.step.completed', turnId: 5, step: 1 })); - feed(ev({ type: 'turn.ended', turnId: 5, reason: 'completed' })); - feed( - ev({ - type: 'turn.steer', - input: [{ type: 'text', text: 'too late' }], - origin: { kind: 'user' }, - }), - ); - expect( - turnOps('t5', tx.getItems()).steps.flatMap((step) => step.frames), - ).toHaveLength(0); - }); - - it('flushes a pending steer into the last step when the turn ends before the next step', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 6, origin: { kind: 'user' }, prompt: 'active' })); - feed(ev({ type: 'turn.step.started', turnId: 6, step: 1 })); - feed(ev({ type: 'turn.step.completed', turnId: 6, step: 1 })); - feed( - ev({ - type: 'prompt.steered', - activePromptId: 'p1', - promptIds: ['p2'], - content: [{ type: 'text', text: 'last word' }], - steeredAt: '2026-01-01T00:00:02.000Z', - }), - ); - feed( - ev({ - type: 'turn.steer', - input: [{ type: 'text', text: 'last word' }], - origin: { kind: 'user' }, - }), - ); - feed(ev({ type: 'turn.ended', turnId: 6, reason: 'cancelled', interruptReason: 'user_cancelled' })); - - const turn = turnOps('t6', tx.getItems()); - const lastStep = turn.steps.at(-1); - expect(lastStep?.frames.at(-1)).toMatchObject({ - kind: 'text', - role: 'user', - text: 'last word', - promptIds: ['p2'], - origin: { kind: 'user' }, - }); - }); - - it('flushes a pending steer into a user-only step when the turn ends before its first step', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'turn.started', turnId: 7, origin: { kind: 'user' }, prompt: 'active' })); - feed( - ev({ - type: 'prompt.steered', - activePromptId: 'p1', - promptIds: ['p2'], - content: [{ type: 'text', text: 'last word' }], - steeredAt: '2026-01-01T00:00:02.000Z', - }), - ); - feed( - ev({ - type: 'turn.steer', - input: [ - { type: 'text', text: 'private instructions' }, - { type: 'text', text: 'last word' }, - ], - origin: { - kind: 'user', - skillActivations: [{ activationId: 'a1', skillName: 'review', skillArgs: 'strict' }], - }, - }), - ); - feed(ev({ type: 'turn.ended', turnId: 7, reason: 'cancelled', interruptReason: 'user_cancelled' })); - - const turn = turnOps('t7', tx.getItems()); - expect(turn.steps).toHaveLength(1); - expect(turn.steps[0]).toMatchObject({ state: 'interrupted' }); - expect(turn.steps[0]?.frames[0]).toMatchObject({ - kind: 'text', - role: 'user', - text: 'last word', - promptIds: ['p2'], - origin: { - kind: 'user', - skillActivations: [{ skillName: 'review', skillArgs: 'strict' }], - }, - }); - }); - - it('buffers turn.steer seen before the projector ever saw turn.started (mid-turn attach)', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const ops: TranscriptOperation[] = []; - const feed = (event: ProjectorBusEvent): void => { - ops.push(...projector.map(event)); - }; - - feed( - ev({ - type: 'prompt.steered', - activePromptId: 'p1', - promptIds: ['p2'], - content: [{ type: 'text', text: 'steered mid-attach' }], - steeredAt: '2026-01-01T00:00:02.000Z', - }), - ); - feed( - ev({ - type: 'turn.steer', - input: [{ type: 'text', text: 'steered mid-attach' }], - origin: { kind: 'user' }, - }), - ); - expect(ops).toHaveLength(2); - expect(ops.every((op) => op.op === 'prompt.upsert')).toBe(true); - - feed(ev({ type: 'turn.step.started', turnId: 3, step: 2 })); - const frameOp = ops.find((op) => op.op === 'frame.upsert'); - expect(frameOp).toMatchObject({ - turnId: 't3', - stepId: 't3.2', - frame: { - kind: 'text', - role: 'user', - text: 'steered mid-attach', - promptIds: ['p2'], - origin: { kind: 'user' }, - }, - }); - }); - - it('readColdSnapshot answers empty for path-hostile agent ids without touching disk', async () => { - const service = new TranscriptService({ - homeDir: '/nonexistent-home', - core: { - accessor: { - get: (token: unknown) => { - if (token === ISessionManager) { - return { get: () => undefined, list: () => [] }; - } - if (token === IWorkspaceInstanceManager) { - return { list: () => [], onDidChange: () => ({ dispose: () => undefined }) }; - } - if (token === ISessionIndex) return { get: async () => ({ workspaceId: 'ws' }) }; - return undefined; - }, - }, - } as unknown as Scope, - }); - for (const hostile of ['../../main', '..', 'a/b', 'a\\b']) { - const snapshot = await service.readColdSnapshot('s1', hostile); - expect(snapshot?.items).toEqual([]); - } - }); - - it('readColdSnapshot folds task/todo/goal/plan/interaction records into the cold snapshot', async () => { - const home = await mkdtemp(join(tmpdir(), 'transcript-cold-facts-')); - try { - const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); - await mkdir(wireDir, { recursive: true }); - const records = [ - { - type: 'context.append_message', - message: { - role: 'user', - content: [{ type: 'text', text: 'hi' }], - toolCalls: [], - origin: { kind: 'user' }, - }, - time: 1000, - }, - { - type: 'context.append_message', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'running' }], - toolCalls: [{ type: 'function', id: 'call_1', name: 'Bash', arguments: '{"command":"ls"}' }], - }, - time: 2000, - }, - { - type: 'tools.update_store', - key: 'todo', - value: [{ title: 'write tests', status: 'in_progress' }], - time: 3000, - }, - { type: 'goal.create', goalId: 'g1', objective: 'fix the bug', time: 4000 }, - { type: 'plan_mode.enter', id: 'plan-1', time: 5000 }, - { - type: 'task.started', - info: { - taskId: 'task_1', - kind: 'process', - description: 'pnpm test', - status: 'running', - startedAt: 6000, - endedAt: null, - }, - time: 6000, - }, - { - type: 'task.terminated', - info: { - taskId: 'task_1', - kind: 'process', - description: 'pnpm test', - status: 'completed', - startedAt: 6000, - endedAt: 9000, - }, - outputTail: '42 passed', - time: 9000, - }, - { - type: 'interaction.request', - id: 'apr-1', - kind: 'approval', - toolCallId: 'call_1', - request: { toolName: 'Bash' }, - time: 7000, - }, - { - type: 'interaction.resolved', - id: 'apr-1', - response: { decision: 'approved' }, - time: 8000, - }, - ]; - await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); - - const service = new TranscriptService({ - homeDir: home, - core: { - accessor: { - get: (token: unknown) => { - if (token === ISessionManager) return { get: () => undefined, list: () => [] }; - if (token === IWorkspaceInstanceManager) { - return { list: () => [], onDidChange: () => ({ dispose: () => undefined }) }; - } - if (token === ISessionIndex) return { get: async () => ({ workspaceId: 'ws' }) }; - return undefined; - }, - }, - } as unknown as Scope, - }); - const snapshot = await service.readColdSnapshot('s1', 'main'); - expect(snapshot).toBeDefined(); - - expect(snapshot!.tasks).toEqual([ - { - taskId: 'task_1', - kind: 'shell', - state: 'completed', - detached: true, - description: 'pnpm test', - agentId: undefined, - outputTail: '42 passed', - startedAt: new Date(6000).toISOString(), - endedAt: new Date(9000).toISOString(), - }, - ]); - expect(snapshot!.todos).toEqual([ - { - todoId: 'todo', - items: [{ title: 'write tests', status: 'in_progress' }], - updatedAt: new Date(3000).toISOString(), - }, - ]); - expect(snapshot!.meta.goal).toMatchObject({ objective: 'fix the bug', status: 'active' }); - expect(snapshot!.meta.modes).toEqual({ plan: {} }); - expect(snapshot!.interactions).toEqual([ - { - interactionId: 'apr-1', - interactionKind: 'approval', - toolCallId: 'call_1', - state: 'approved', - request: { toolName: 'Bash' }, - response: { decision: 'approved' }, - }, - ]); - - const standalone = snapshot!.items.filter((item) => item.kind !== 'turn'); - expect(standalone).toEqual([ - expect.objectContaining({ kind: 'marker', marker: 'goal', markerId: 'm1' }), - expect.objectContaining({ kind: 'marker', marker: 'plan.enter', markerId: 'm2' }), - expect.objectContaining({ kind: 'taskref', refId: 'ref-task_1', taskId: 'task_1' }), - ]); - service.dropSession('s1'); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('readColdSnapshot projects question requests onto the wire shape without rewriting the log', async () => { - const home = await mkdtemp(join(tmpdir(), 'transcript-cold-question-')); - try { - const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); - await mkdir(wireDir, { recursive: true }); - const records = [ - { - type: 'interaction.request', - id: 'q-cold', - kind: 'question', - toolCallId: 'call_q', - request: { - toolCallId: 'call_q', - questions: [{ question: 'Pick', options: [{ label: 'A' }, { label: 'B' }] }], - }, - time: 7000, - }, - { - type: 'interaction.request', - id: 'q-inner', - kind: 'question', - request: { - toolCallId: 'call_inner', - questions: [{ question: 'Inner', options: [{ label: 'X' }] }], - }, - time: 8500, - }, - { - type: 'interaction.request', - id: 'q-bad', - kind: 'question', - request: { toolName: 'nope' }, - time: 8000, - }, - { - type: 'interaction.request', - id: 'apr-1', - kind: 'approval', - toolCallId: 'call_1', - request: { toolName: 'Bash' }, - time: 9000, - }, - ]; - const wireFile = join(wireDir, 'wire.jsonl'); - const content = `${records.map((r) => JSON.stringify(r)).join('\n')}\n`; - await writeFile(wireFile, content); - - const service = coldTranscriptService(home); - const snapshot = await service.readColdSnapshot('s1', 'main'); - const byId = new Map(snapshot!.interactions.map((i) => [i.interactionId, i])); - expect(byId.get('q-cold')).toMatchObject({ - interactionKind: 'question', - toolCallId: 'call_q', - state: 'cancelled', - }); - expect(byId.get('q-cold')?.request).toEqual({ - question_id: 'q-cold', - session_id: 's1', - questions: [ - { - id: 'q_0', - question: 'Pick', - options: [ - { id: 'opt_0_0', label: 'A' }, - { id: 'opt_0_1', label: 'B' }, - ], - allow_other: true, - }, - ], - created_at: new Date(7000).toISOString(), - tool_call_id: 'call_q', - }); - expect(byId.get('q-bad')?.request).toEqual({ toolName: 'nope' }); - expect(byId.get('q-inner')).toMatchObject({ toolCallId: 'call_inner' }); - expect(byId.get('q-inner')?.request).toMatchObject({ - question_id: 'q-inner', - tool_call_id: 'call_inner', - }); - expect(byId.get('apr-1')?.request).toEqual({ toolName: 'Bash' }); - await expect(readFile(wireFile, 'utf-8')).resolves.toBe(content); - service.dropSession('s1'); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('readColdSnapshot derives meta.activity from the final turn state when no live session exists', async () => { - const home = await mkdtemp(join(tmpdir(), 'transcript-cold-activity-')); - try { - const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); - await mkdir(wireDir, { recursive: true }); - const write = async (records: unknown[]): Promise => - writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); - const user = { type: 'context.append_message', message: { id: 'prompt-1', role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }; - const assistant = { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] }, time: 2000 }; - const boundary = { type: 'turn.prompt', input: [{ type: 'text', text: 'hi' }], origin: { kind: 'user' }, promptId: 'prompt-1', time: 500 }; - - await write([boundary, user, assistant, { type: 'turn.ended', turnId: 0, reason: 'completed', time: 3000 }]); - const ended = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); - expect(ended!.meta.activity).toBe('idle'); - expect(ended!.items[0]).toMatchObject({ triggerPromptId: 'prompt-1' }); - - await write([boundary, user, assistant]); - const dangling = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); - expect(dangling!.meta.activity).toBe('idle'); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('readColdSnapshot opens a task-origin turn only when the wire has the turn.prompt boundary', async () => { - const home = await mkdtemp(join(tmpdir(), 'transcript-cold-taskturn-')); - try { - const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); - await mkdir(wireDir, { recursive: true }); - const notification = - '\nTitle: Background agent completed\nSeverity: info\ninspect done.\n'; - const taskOrigin = { kind: 'task', taskId: 'task_9', status: 'completed', notificationId: 'n1' }; - const opening = [ - { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }, - { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] }, time: 2000 }, - ]; - const boundary = { type: 'turn.prompt', input: [{ type: 'text', text: notification }], origin: taskOrigin, time: 3000 }; - const delivered = { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: notification }], toolCalls: [], origin: taskOrigin }, time: 4000 }; - const reply = { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'reporting back' }], toolCalls: [] }, time: 5000 }; - const write = async (records: unknown[]): Promise => - writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); - - await write([...opening, boundary, delivered, reply]); - const withBoundary = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); - const originKinds = withBoundary!.items - .filter((item) => item.kind === 'turn') - .map((item) => (item.kind === 'turn' ? item.origin.kind : '')); - expect(originKinds).toEqual(['user', 'task']); - - await write([...opening, delivered, reply]); - const withoutBoundary = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); - const turns = withoutBoundary!.items.filter((item) => item.kind === 'turn'); - expect(turns).toHaveLength(1); - const turn = turns[0]; - if (turn?.kind !== 'turn') throw new Error('expected turn'); - expect( - turn.steps.flatMap((step) => step.frames).some((f) => f.kind === 'text' && f.role === 'user'), - ).toBe(true); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('readColdSnapshot folds a steered user message into its turn instead of opening a new one', async () => { - const home = await mkdtemp(join(tmpdir(), 'transcript-cold-steer-')); - try { - const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); - await mkdir(wireDir, { recursive: true }); - const records = [ - { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }, - { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] }, time: 2000 }, - { type: 'turn.steer', input: [{ type: 'text', text: 'steered in' }], origin: { kind: 'user' }, time: 3000 }, - { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'steered in' }], toolCalls: [], origin: { kind: 'user' } }, time: 3001 }, - { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, time: 4000 }, - ]; - await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); - - const snapshot = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); - const turns = snapshot!.items.filter((item) => item.kind === 'turn'); - expect(turns).toHaveLength(1); - const turn = turns[0]; - if (turn?.kind !== 'turn') throw new Error('expected turn'); - expect(turn.steps).toHaveLength(2); - expect(turn.steps[1]?.frames[0]).toMatchObject({ - kind: 'text', - role: 'user', - text: 'steered in', - origin: { kind: 'user' }, - }); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('readColdSnapshot preserves safe bundled skill provenance before the first step', async () => { - const home = await mkdtemp(join(tmpdir(), 'transcript-cold-bundled-steer-')); - try { - const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); - await mkdir(wireDir, { recursive: true }); - const origin = { - kind: 'user', - skillActivations: [ - { activationId: 'a1', skillName: 'deploy', skillPath: '/private/deploy/SKILL.md' }, - { activationId: 'a2', skillName: 'review', skillArgs: 'strict', skillPath: '/private/review/SKILL.md' }, - ], - attachments: [{ - name: 'secret.txt', - mediaType: 'text/plain', - size: 12, - path: '/private/secret.txt', - }], - }; - const content = [ - { type: 'text', text: 'private instructions' }, - { type: 'text', text: 'private instructions' }, - { type: 'text', text: 'steered in' }, - ]; - const records = [ - { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }, - { type: 'turn.steer', input: content, origin, time: 3000 }, - { type: 'context.append_message', message: { role: 'user', content, toolCalls: [], origin }, time: 3001 }, - ]; - await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); - - const snapshot = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); - const turn = snapshot?.items.find((item) => item.kind === 'turn'); - if (turn?.kind !== 'turn') throw new Error('expected turn'); - const frame = turn.steps.flatMap((step) => step.frames).find( - (candidate) => candidate.kind === 'text' && candidate.role === 'user', - ); - expect(frame).toMatchObject({ - kind: 'text', - role: 'user', - text: 'steered in', - origin: { - kind: 'user', - skillActivations: [ - { skillName: 'deploy' }, - { skillName: 'review', skillArgs: 'strict' }, - ], - }, - }); - expect(JSON.stringify(frame)).not.toContain('/private/'); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('readColdSnapshot keeps skill-activation steers as skill markers instead of user frames', async () => { - const home = await mkdtemp(join(tmpdir(), 'transcript-cold-skillsteer-')); - try { - const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); - await mkdir(wireDir, { recursive: true }); - const skillOrigin = { - kind: 'skill_activation', - activationId: 'a1', - skillName: 'write-tui', - trigger: 'model-tool', - skillSource: 'project', - }; - const nestedOrigin = { ...skillOrigin, activationId: 'a2', skillName: 'design', trigger: 'nested-skill' }; - const skillText = 'Skill tool loaded instructions for this request. Follow them.'; - const nestedText = 'Nested skill instructions.'; - const records = [ - { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'active' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }, - { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'working' }], toolCalls: [] }, time: 2000 }, - { type: 'turn.steer', input: [{ type: 'text', text: skillText }], origin: skillOrigin, time: 3000 }, - { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: skillText }], toolCalls: [], origin: skillOrigin }, time: 3001 }, - { type: 'turn.steer', input: [{ type: 'text', text: nestedText }], origin: nestedOrigin, time: 3002 }, - { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: nestedText }], toolCalls: [], origin: nestedOrigin }, time: 3003 }, - { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'noted' }], toolCalls: [] }, time: 4000 }, - ]; - await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); - - const snapshot = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); - const markers = snapshot!.items.filter((item) => item.kind === 'marker'); - expect(markers).toHaveLength(2); - expect(markers.every((item) => item.kind === 'marker' && item.marker === 'skill')).toBe(true); - const turns = snapshot!.items.filter((item) => item.kind === 'turn'); - expect(turns).toHaveLength(1); - const turn = turns[0]; - if (turn?.kind !== 'turn') throw new Error('expected turn'); - expect(turn.prompt).toBe('active'); - const userFrames = turn.steps - .flatMap((step) => step.frames) - .filter((frame) => frame.kind === 'text' && frame.role === 'user'); - expect(userFrames).toHaveLength(0); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('readColdSnapshot drops undone task-turn boundaries so a redelivered notification folds', async () => { - const home = await mkdtemp(join(tmpdir(), 'transcript-cold-undoboundary-')); - try { - const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); - await mkdir(wireDir, { recursive: true }); - const notification = - '\nTitle: Background agent completed\nSeverity: info\ninspect done.\n'; - const taskOrigin = { kind: 'task', taskId: 'task_9', status: 'completed', notificationId: 'n1' }; - const opening = [ - { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [], origin: { kind: 'user' } }, time: 1000 }, - { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'answer' }], toolCalls: [] }, time: 2000 }, - ]; - const boundary = { type: 'turn.prompt', input: [{ type: 'text', text: notification }], origin: taskOrigin, time: 3000 }; - const delivered = { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: notification }], toolCalls: [], origin: taskOrigin }, time: 4000 }; - const reply = { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'reporting back' }], toolCalls: [] }, time: 5000 }; - const again = { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: 'again' }], toolCalls: [], origin: { kind: 'user' } }, time: 7000 }; - const answer2 = { type: 'context.append_message', message: { role: 'assistant', content: [{ type: 'text', text: 'answer2' }], toolCalls: [] }, time: 8000 }; - const redelivered = { type: 'context.append_message', message: { role: 'user', content: [{ type: 'text', text: notification }], toolCalls: [], origin: taskOrigin }, time: 9000 }; - const write = async (records: unknown[]): Promise => - writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); - - await write([...opening, boundary, delivered, reply, again, answer2, redelivered]); - const withBoundary = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); - const originKinds = withBoundary!.items - .filter((item) => item.kind === 'turn') - .map((item) => (item.kind === 'turn' ? item.origin.kind : '')); - expect(originKinds).toEqual(['user', 'task', 'user', 'task']); - - await write([...opening, boundary, delivered, reply, { type: 'context.undo', count: 1, time: 6000 }, again, answer2, redelivered]); - const withUndo = await coldTranscriptService(home).readColdSnapshot('s1', 'main'); - const undoTurns = withUndo!.items.filter((item) => item.kind === 'turn'); - expect(undoTurns).toHaveLength(1); - const undoTurn = undoTurns[0]; - if (undoTurn?.kind !== 'turn') throw new Error('expected turn'); - expect(undoTurn.origin.kind).toBe('user'); - expect( - undoTurn.steps - .flatMap((step) => step.frames) - .some((f) => f.kind === 'text' && f.text.includes('inspect done')), - ).toBe(false); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('gates the cold tower mode badge behind the tower experiment flag', async () => { - const home = await mkdtemp(join(tmpdir(), 'transcript-cold-tower-')); - try { - const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); - await mkdir(wireDir, { recursive: true }); - const records = [ - { - type: 'context.append_message', - message: { - role: 'user', - content: [{ type: 'text', text: 'hi' }], - toolCalls: [], - origin: { kind: 'user' }, - }, - time: 1000, - }, - { type: 'tower_mode.enter', time: 2000 }, - ]; - await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); - - const serviceWith = ( - flagOn: boolean, - opts: { cwd?: string; liveSessionIds?: string[] } = {}, - ) => - new TranscriptService({ - homeDir: home, - core: { - accessor: { - get: (token: unknown) => { - if (token === ISessionManager) { - return { - get: (id: string) => (opts.liveSessionIds?.includes(id) ? {} : undefined), - list: () => [], - }; - } - if (token === IWorkspaceInstanceManager) { - return { list: () => [], onDidChange: () => ({ dispose: () => undefined }) }; - } - if (token === ISessionIndex) { - return { get: async () => ({ workspaceId: 'ws', cwd: opts.cwd }) }; - } - if (token === IFlagService) { - return { enabled: (id: string) => flagOn && id === TOWER_FLAG_ID }; - } - return undefined; - }, - }, - } as unknown as Scope, - }); - - const withFlag = await serviceWith(true).readColdSnapshot('s1', 'main'); - expect(withFlag!.meta.modes).toEqual({ tower: {} }); - - const withoutFlag = await serviceWith(false).readColdSnapshot('s1', 'main'); - expect(withoutFlag!.meta.modes).toBeUndefined(); - - _setTowerFeatureAssembledForTests(false); - try { - const notAssembled = await serviceWith(true).readColdSnapshot('s1', 'main'); - expect(notAssembled!.meta.modes).toBeUndefined(); - } finally { - _setTowerFeatureAssembledForTests(true); - } - - const repo = await mkdtemp(join(tmpdir(), 'tower-cold-owner-')); - try { - await execFileAsync('git', ['init', '-b', 'main'], { cwd: repo }); - await execFileAsync('git', ['config', 'user.email', 'tower-test@example.com'], { cwd: repo }); - await execFileAsync('git', ['config', 'user.name', 'Tower Test'], { cwd: repo }); - await writeFile(join(repo, 'README.md'), '# fixture\n'); - await execFileAsync('git', ['add', 'README.md'], { cwd: repo }); - await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: repo }); - await new TowerStore(repo).init('session-b'); - - const adoptedByLive = await serviceWith(true, { - cwd: repo, - liveSessionIds: ['session-b'], - }).readColdSnapshot('s1', 'main'); - expect(adoptedByLive!.meta.modes).toBeUndefined(); - - const adoptedByDead = await serviceWith(true, { cwd: repo }).readColdSnapshot('s1', 'main'); - expect(adoptedByDead!.meta.modes).toEqual({ tower: {} }); - } finally { - await rm(repo, { recursive: true, force: true }); - } - - const childDir = join(home, 'sessions', 'ws', 's1', 'agents', 'worker-1'); - await mkdir(childDir, { recursive: true }); - await writeFile( - join(childDir, 'wire.jsonl'), - `${records.map((r) => JSON.stringify(r)).join('\n')}\n`, - ); - const child = await serviceWith(true).readColdSnapshot('s1', 'worker-1'); - expect(child!.meta.modes).toBeUndefined(); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('folds blocked turn endings into failed (engine wire contract)', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - tx.apply(projector.map(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' } }))); - tx.apply(projector.map(ev({ type: 'turn.ended', turnId: 0, reason: 'blocked' }))); - expect(turnOps('t0', tx.getItems()).state).toBe('failed'); - }); - - it('tracks the prompt queue from accepted/queued through terminal', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'prompt.accepted', promptId: 'p1' })); - expect(tx.getPrompt('p1')).toMatchObject({ status: 'running' }); - feed(ev({ type: 'prompt.queued', promptId: 'p2', content: [{ type: 'text', text: 'later' }], queueLength: 1 })); - expect(tx.getPrompt('p2')).toMatchObject({ status: 'queued' }); - feed(ev({ type: 'prompt.completed', promptId: 'p2', finishedAt: '2026-08-20T00:00:01.000Z', reason: 'completed' })); - expect(tx.getPrompt('p2')).toMatchObject({ status: 'completed' }); - feed(ev({ type: 'prompt.aborted', promptId: 'p1', abortedAt: '2026-08-20T00:00:02.000Z' })); - expect(tx.getPrompt('p1')).toMatchObject({ status: 'aborted' }); - }); - - it('mirrors turn liveness into meta.activity', () => { const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - expect(tx.getMeta().activity).toBeUndefined(); - feed(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - expect(tx.getMeta().activity).toBe('turn'); - feed(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - expect(tx.getMeta().activity).toBe('idle'); - feed(ev({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } })); - expect(tx.getMeta().activity).toBe('turn'); - feed(ev({ type: 'turn.ended', turnId: 2, reason: 'failed' })); - expect(tx.getMeta().activity).toBe('idle'); - }); - - it('maps cron / task origins onto the turn header', () => { const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed( - ev({ - type: 'turn.started', - turnId: 1, - origin: { kind: 'cron_job', jobId: 'job-9', cron: '* * * * *' }, - }), - ); - feed( - ev({ - type: 'turn.started', - turnId: 2, - origin: { kind: 'task', taskId: 'bash-1', status: 'completed', notificationId: 'n1' }, - }), - ); - - expect(turnOps('t1', tx.getItems()).origin).toEqual({ - kind: 'cron', - taskId: 'job-9', - payload: { kind: 'cron_job', jobId: 'job-9', cron: '* * * * *' }, - }); - expect(turnOps('t2', tx.getItems()).origin).toEqual({ - kind: 'task', - taskId: 'bash-1', - payload: { kind: 'task', taskId: 'bash-1', status: 'completed', notificationId: 'n1' }, - }); - }); - - it('treats subagent.started/failed/suspended within the running→failed vocabulary', () => { - const projector = new AgentTranscriptProjector('main', TEST_SESSION_ID); - const tx = new AgentTranscript('main'); - const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event)); - - feed(ev({ type: 'subagent.started', subagentId: 'agent-1' })); - expect(tx.getTask('agent-1')).toMatchObject({ kind: 'subagent', state: 'running' }); - feed(ev({ type: 'subagent.suspended', subagentId: 'agent-1', reason: 'approval' })); - expect(tx.getTask('agent-1')).toMatchObject({ state: 'running', stateReason: 'approval' }); - feed(ev({ type: 'subagent.failed', subagentId: 'agent-1', error: 'boom' })); - expect(tx.getTask('agent-1')).toMatchObject({ state: 'failed', error: 'boom' }); - - feed( - ev({ - type: 'subagent.completed', - subagentId: 'agent-2', - resultSummary: 'found 3 files', - usage: { inputOther: 10, output: 5, inputCacheRead: 2, inputCacheCreation: 1 }, - }), - ); - expect(tx.getTask('agent-2')).toMatchObject({ - state: 'completed', - resultSummary: 'found 3 files', - usage: { inputOther: 10, output: 5, inputCacheRead: 2, inputCacheCreation: 1 }, - }); - }); -}); - -describe('AgentTranscript transcript task vocabulary', () => { - it('documents the task states used by the projector', () => { - const states: Array = [ - 'running', - 'completed', - 'failed', - 'timed_out', - 'killed', - 'lost', - ]; - expect(states).toHaveLength(6); - }); -}); - -describe('bindSessionTranscript', () => { - class FakeBus { - private readonly handlers = new Set<(event: Event2) => void>(); - subscribe(cb: (event: Event2) => void): { dispose: () => void } { - this.handlers.add(cb); - return { dispose: () => this.handlers.delete(cb) }; - } - emit(event: Event2): void { - for (const cb of this.handlers) cb(event); - } - } - - - interface FakeAgentHandle { - readonly id: string; - readonly context: AgentContext; - readonly bus: FakeBus; - readonly accessor: { get: (token: unknown) => unknown }; - } - - class FakeAgents { - private readonly handles = new Map(); - private readonly createHandlers = new Set<(context: AgentContext) => void>(); - private readonly closeHandlers = new Set<(context: AgentContext) => void>(); - - list(): AgentContext[] { - return [...this.handles.values()].map((handle) => handle.context); - } - get(agentId: string): AgentContext | undefined { - return this.handles.get(agentId)?.context; - } - handleOf(agentId: string): FakeAgentHandle | undefined { - return this.handles.get(agentId); - } - byId(id: string): FakeAgentHandle | undefined { - return this.handles.get(id); - } - onDidCreate(cb: (context: AgentContext) => void): { dispose: () => void } { - this.createHandlers.add(cb); - return { dispose: () => this.createHandlers.delete(cb) }; - } - onDidClose(cb: (context: AgentContext) => void): { dispose: () => void } { - this.closeHandlers.add(cb); - return { dispose: () => this.closeHandlers.delete(cb) }; - } - add(id: string, opts?: { loopStatus?: unknown; tasks?: readonly unknown[]; activePromptId?: string }): FakeAgentHandle { - const bus = this.handles.get(id)?.bus ?? new FakeBus(); - const scope = makeAgentScopeContext({ - agentId: id, - agentScope: `agents/${id}`, - generation: 1, - }); - let activity: AgentActivitySnapshot = {}; - bus.subscribe((event) => { - if (event.type === 'turn.started') { - activity = { - turn: { - turnId: (event as { turnId?: number }).turnId ?? 0, - phase: 'running', - step: 1, - ending: false, - activeToolCalls: [], - since: 0, - }, - }; - } else if (event.type === 'turn.ended') { - activity = {}; - } - }); - const handle: FakeAgentHandle = { - id, - context: scope.agentContext, - bus, - accessor: { - get: (token: unknown) => { - if (token === IAgentScopeContext) return scope; - if (token === IEventBus) return bus; - if (token === IAgentLoopService) { - return { - status: () => - opts?.loopStatus ?? { state: activity.turn === undefined ? 'idle' : 'running' }, - activitySnapshot: () => activity, - }; - } - if (token === IAgentPromptService) { - return { - list: () => ({ - active: opts?.activePromptId === undefined - ? undefined - : { - id: opts.activePromptId, - userMessageId: opts.activePromptId, - createdAt: '2026-01-01T00:00:00.000Z', - state: 'running', - message: { - role: 'user', - content: [{ type: 'text', text: 'hi' }], - toolCalls: [], - origin: { kind: 'user' }, - }, - }, - pending: [], - }), - }; - } - if (token === IAgentTaskService) { - return { list: () => opts?.tasks ?? [] }; - } - return undefined; - }, - }, - }; - this.handles.set(id, handle); - for (const cb of this.createHandlers) cb(handle.context); - return handle; - } - remove(id: string): void { - const removed = this.handles.get(id); - this.handles.delete(id); - if (removed !== undefined) { - for (const cb of this.closeHandlers) cb(removed.context); - } - } - } - - function fakeSession(manager: FakeAgents): ISessionScopeHandle { - return { - id: 's1', - accessor: { - get: (token: unknown) => { - if (token === IAgentLifecycleService) return manager; - if (token === ISessionMetadata) return { read: async () => ({ agents: {} }) }; - return undefined; - }, - }, - } as unknown as ISessionScopeHandle; - } - - afterEach(() => { - interactions.purgeSession('s1'); - }); - - it('registers pre-bind pendings without frames and replays an early resolve at seed time', () => { - const agents = new FakeAgents(); - interactions.enqueue({ - id: 'apr-1', - kind: 'approval', - payload: { toolCallId: 'call_1' }, - tags: { agentId: 'main', sessionId: 's1', turnId: 0 }, - }); - - const store = new TranscriptStore('s1'); - const ops: TranscriptOperation[] = []; - const binding = bindSessionTranscript(store, fakeSession(agents), undefined, (event) => - ops.push(...event.ops), - ); - - expect(ops).toHaveLength(0); - - interactions.respond('apr-1', { decision: 'approved' }); - expect(ops).toHaveLength(0); - - binding.seedPendingInteractions(); - const states = ops - .filter((op): op is InteractionUpsertOp => op.op === 'interaction.upsert') - .map((op) => op.interaction.state); - expect(states).toEqual(['pending', 'approved']); - binding.dispose(); - }); - - it('keeps the materialized transcript and roster entry when an agent is disposed', () => { - const agents = new FakeAgents(); - const store = new TranscriptStore('s1'); - const binding = bindSessionTranscript( - store, - fakeSession(agents), - ); - - const sub = agents.add('sub-1'); - agents.add('main'); - sub.bus.emit(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' }, prompt: 'scan' })); - sub.bus.emit(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); - expect(store.getAgent('sub-1')?.getItems()).toHaveLength(1); - - agents.remove('sub-1'); - expect(store.getAgent('sub-1')?.getItems()).toHaveLength(1); - const descriptor = store.agents().find((a) => a.agentId === 'sub-1'); - expect(descriptor).toBeDefined(); - expect(typeof descriptor?.disposedAt).toBe('string'); - expect(store.agents().find((a) => a.agentId === 'main')?.disposedAt).toBeUndefined(); - binding.dispose(); - }); - - it('seeds pre-attach Agent task mappings so a late-bound projector folds the lifecycle', () => { - const agents = new FakeAgents(); - agents.add('main', { - tasks: [ - { - taskId: 'task-9', - kind: 'agent', - agentId: 'agent-1', - status: 'running', - description: 'Inspect', - detached: false, - startedAt: 1_700_000_000_000, - }, - ], - }); - const store = new TranscriptStore('s1'); - const binding = bindSessionTranscript( - store, - fakeSession(agents), - ); - - expect(store.getAgent('main')?.getTask('task-9')).toMatchObject({ - kind: 'subagent', - state: 'running', - detached: false, - description: 'Inspect', - agentId: 'agent-1', - }); - - agents.byId('main')!.bus.emit(ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' })); - - expect(store.getAgent('main')?.getTask('task-9')).toMatchObject({ - state: 'completed', - resultSummary: 'done', - detached: false, - }); - expect(store.getAgent('main')?.getTask('agent-1')).toBeUndefined(); - binding.dispose(); - }); - - const SHOT_PNG_UPLOAD = { - type: 'file', - file_id: 'file_1', - media_type: 'image/png', - name: 'shot.png', - }; - - async function seedWireHome(attachment?: Record): Promise { - const home = await mkdtemp(join(tmpdir(), 'transcript-overlay-')); - const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); - await mkdir(wireDir, { recursive: true }); - const records: Record[] = [ - { - type: 'context.append_message', - message: { - role: 'user', - content: - attachment === undefined - ? [{ type: 'text', text: 'hi' }] - : [{ type: 'text', text: 'what is this?' }, attachment], - toolCalls: [], - origin: { kind: 'user' }, - }, - time: new Date().toISOString(), - }, - ]; - if (attachment !== undefined) { - records.push({ - type: 'context.append_message', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'a screenshot' }], - toolCalls: [], - }, - time: new Date().toISOString(), - }); - } - await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); - return home; - } - - function fakeCoreWithAgents(agents: FakeAgents): Scope { - const sessionLifecycle = { - onDidCloseSession: () => ({ dispose: () => undefined }), - onDidArchiveSession: () => ({ dispose: () => undefined }), - get: (sid: string) => (sid === 's1' ? fakeSession(agents) : undefined), - }; - const handler = { - id: 'ws', - kind: 'program', - accessor: { - get: (t: unknown) => (t === ISessionLifecycleService ? sessionLifecycle : undefined), - }, - dispose: () => undefined, - }; - return { - accessor: { - get: (token: unknown) => { - if (token === ISessionManager) { - return { get: sessionLifecycle.get, list: () => [sessionLifecycle.get('s1')] }; - } - if (token === IWorkspaceInstanceManager) { - return { - list: () => [{ program: { accessor: handler.accessor } }], - onDidChange: () => ({ dispose: () => undefined }), - }; - } - if (token === ISessionIndex) return { get: async () => ({ workspaceId: 'ws' }) }; - return undefined; - }, - }, - } as unknown as Scope; - } - - it('stops projecting for an agent once it is disposed', () => { - const agents = new FakeAgents(); - const store = new TranscriptStore('s1'); - const binding = bindSessionTranscript( - store, - fakeSession(agents), - ); - - const sub = agents.add('sub-1'); - sub.bus.emit(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' }, prompt: 'scan' })); - expect(store.getAgent('sub-1')?.getItems()).toHaveLength(1); - - agents.remove('sub-1'); - sub.bus.emit(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); - expect(store.getAgent('sub-1')?.getItems()[0]).toMatchObject({ kind: 'turn', state: 'running' }); - binding.dispose(); - }); - - it('heals a kind-mismatched frame instead of skipping it on length', () => { - const snapshotTurn: TranscriptTurn = { - kind: 'turn', - turnId: 't0', - ordinal: 0, - state: 'completed', - origin: { kind: 'user' }, - steps: [ - { - kind: 'step', - stepId: 't0.1', - turnId: 't0', - ordinal: 1, - state: 'completed', - frames: [ - { kind: 'thinking', frameId: 't0.1.f1', text: 'hmm' }, - { kind: 'text', frameId: 't0.1.f2', role: 'assistant', text: 'Hello world' }, - ], - }, - ], - }; - const liveTurn: TranscriptTurn = { - kind: 'turn', - turnId: 't0', - ordinal: 0, - state: 'completed', - origin: { kind: 'user' }, - steps: [ - { - kind: 'step', - stepId: 't0.1', - turnId: 't0', - ordinal: 1, - state: 'completed', - frames: [{ kind: 'text', frameId: 't0.1.f1', role: 'assistant', text: 'world' }], - }, - ], - }; - - const frames = healTurnOps(snapshotTurn, liveTurn) - .filter((op): op is FrameUpsertOp => op.op === 'frame.upsert') - .map((op) => op.frame); - expect(frames).toContainEqual(expect.objectContaining({ kind: 'thinking', frameId: 't0.1.f1', text: 'hmm' })); - expect(frames).toContainEqual(expect.objectContaining({ kind: 'text', frameId: 't0.1.f2', text: 'Hello world' })); - }); - - it('heals missing tool frames and missed results, keeps richer live ones', () => { - const makeTurn = (frames: TranscriptTurn['steps'][number]['frames']): TranscriptTurn => ({ - kind: 'turn', - turnId: 't0', - ordinal: 0, - state: 'completed', - origin: { kind: 'user' }, - steps: [ - { kind: 'step', stepId: 't0.1', turnId: 't0', ordinal: 1, state: 'completed', frames }, - ], - }); - const snapshotTurn = makeTurn([ - { kind: 'tool', frameId: 't0.1.call_1', toolCallId: 'call_1', name: 'Bash', state: 'done', input: { command: 'ls' }, output: 'a.txt' }, - { kind: 'tool', frameId: 't0.1.call_2', toolCallId: 'call_2', name: 'Read', state: 'done', input: {}, output: 'x' }, - { kind: 'tool', frameId: 't0.1.call_3', toolCallId: 'call_3', name: 'Bash', state: 'done', input: {}, output: 'y' }, - ]); - const liveTurn = makeTurn([ - { kind: 'tool', frameId: 't0.1.call_1', toolCallId: 'call_1', name: 'Bash', state: 'running', input: { command: 'ls' }, display: { kind: 'command', command: 'ls' } }, - { kind: 'tool', frameId: 't0.1.call_2', toolCallId: 'call_2', name: 'Read', state: 'done', input: {}, output: 'live-out' }, - ]); - - const frames = healTurnOps(snapshotTurn, liveTurn) - .filter((op): op is FrameUpsertOp => op.op === 'frame.upsert') - .map((op) => op.frame); - expect(frames).toHaveLength(2); - expect(frames).toContainEqual( - expect.objectContaining({ - frameId: 't0.1.call_1', - state: 'done', - output: 'a.txt', - display: { kind: 'command', command: 'ls' }, - }), - ); - expect(frames).toContainEqual(expect.objectContaining({ frameId: 't0.1.call_3', output: 'y' })); - }); - - it('heal keeps the live attachment ids over the snapshot cold ids', () => { - const makeTurn = (attachmentIds: string[] | undefined): TranscriptTurn => ({ - kind: 'turn', - turnId: 't0', - ordinal: 0, - state: 'completed', - origin: { kind: 'user' }, - attachmentIds, - steps: [], - }); - const header = healTurnOps(makeTurn(['att_1']), makeTurn(['t0.att1'])).find( - (op) => op.op === 'turn.upsert', - ); - expect(header).toMatchObject({ turn: { attachmentIds: ['t0.att1'] } }); - const fallback = healTurnOps(makeTurn(['att_1']), makeTurn(undefined)).find( - (op) => op.op === 'turn.upsert', - ); - expect(fallback).toMatchObject({ turn: { attachmentIds: ['att_1'] } }); - }); - - it('heal keeps the live trigger prompt id over a cold turn without one', () => { - const makeTurn = (triggerPromptId: string | undefined): TranscriptTurn => ({ - kind: 'turn', - turnId: 't0', - triggerPromptId, - ordinal: 0, - state: 'completed', - origin: { kind: 'user' }, - steps: [], - }); - const header = healTurnOps(makeTurn(undefined), makeTurn('prompt-1')).find( - (op) => op.op === 'turn.upsert', - ); - expect(header).toMatchObject({ turn: { triggerPromptId: 'prompt-1' } }); - }); - - it('terminal turn.upsert inherits the backfilled header when the projector missed turn.started', () => { - const agents = new FakeAgents(); - const store = new TranscriptStore('s1'); - const ops: TranscriptOperation[] = []; - const binding = bindSessionTranscript( - store, - fakeSession(agents), - undefined, - (event) => ops.push(...event.ops), - ); - const main = agents.add('main'); - - store.ensureAgent('main').apply([ - { - op: 'attachment.upsert', - attachment: { - attachmentId: 'att_1', - mediaType: 'image/*', - name: 'shot.png', - source: { kind: 'file', fileId: 'f_1' }, - }, - }, - { - op: 'turn.upsert', - turn: { - kind: 'turn', - turnId: 't0', - ordinal: 0, - state: 'running', - origin: { kind: 'user' }, - prompt: 'hi', - attachmentIds: ['att_1'], - startedAt: '2026-08-04T00:00:00.000Z', - }, - }, - ]); - - main.bus.emit(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); - - const terminal = ops.filter((op) => op.op === 'turn.upsert'); - expect(terminal).toHaveLength(1); - expect(terminal[0]).toMatchObject({ - turn: { - turnId: 't0', - state: 'completed', - origin: { kind: 'user' }, - prompt: 'hi', - attachmentIds: ['att_1'], - startedAt: '2026-08-04T00:00:00.000Z', - }, - }); - expect(store.getAgent('main')?.getTurn('t0')).toMatchObject({ - state: 'completed', - prompt: 'hi', - attachmentIds: ['att_1'], - }); - binding.dispose(); - }); - - it('seeds pending interactions per agent, not before that agent is backfilled', () => { - const agents = new FakeAgents(); - interactions.enqueue({ id: 'q-main', kind: 'question', payload: { toolCallId: 'call_main' }, tags: { agentId: 'main', sessionId: 's1', turnId: 0 } }); - interactions.enqueue({ id: 'q-sub', kind: 'question', payload: { toolCallId: 'call_sub' }, tags: { agentId: 'sub-1', sessionId: 's1', turnId: 0 } }); - - const store = new TranscriptStore('s1'); - const byAgent = new Map(); - const binding = bindSessionTranscript(store, fakeSession(agents), undefined, (event) => { - byAgent.set(event.agentId, [...(byAgent.get(event.agentId) ?? []), ...event.ops]); - }); - - binding.seedPendingInteractions('main'); - expect([...byAgent.keys()]).toEqual(['main']); - - binding.seedPendingInteractions('sub-1'); - expect([...byAgent.keys()].toSorted()).toEqual(['main', 'sub-1']); - binding.dispose(); - }); - - it('projects live question entities with the same wire shape as the legacy question event', () => { - const agents = new FakeAgents(); - const asked = interactions.enqueue({ - id: 'q-parity', - kind: 'question', - payload: { - questions: [ - { - question: 'Pick one', - options: [{ label: 'A', description: 'first' }, { label: 'B' }], - }, - ], - }, - tags: { agentId: 'main', sessionId: 's1', turnId: 0 }, - }); - const store = new TranscriptStore('s1'); - const binding = bindSessionTranscript(store, fakeSession(agents)); - - binding.seedPendingInteractions('main'); - - const entity = store.getAgent('main')?.getInteraction('q-parity'); - expect(entity?.state).toBe('pending'); - expect(entity?.request).toEqual(toWireQuestion(asked, 's1')); - binding.dispose(); - }); - - it('defers pendings created before their owning agent is seeded', () => { - const agents = new FakeAgents(); - const store = new TranscriptStore('s1'); - const byAgent = new Map(); - const binding = bindSessionTranscript(store, fakeSession(agents), undefined, (event) => { - byAgent.set(event.agentId, [...(byAgent.get(event.agentId) ?? []), ...event.ops]); - }); - - interactions.enqueue({ id: 'q-sub', kind: 'question', payload: { toolCallId: 'call_sub' }, tags: { agentId: 'sub-1', sessionId: 's1', turnId: 0 } }); - expect(byAgent.size).toBe(0); - - binding.seedPendingInteractions('main'); - expect(byAgent.size).toBe(0); - - binding.seedPendingInteractions('sub-1'); - expect([...byAgent.keys()]).toEqual(['sub-1']); - binding.dispose(); - }); - - it('announces pendings from live-created agents immediately (their projector is complete)', () => { - const agents = new FakeAgents(); - const store = new TranscriptStore('s1'); - const byAgent = new Map(); - const binding = bindSessionTranscript(store, fakeSession(agents), undefined, (event) => { - byAgent.set(event.agentId, [...(byAgent.get(event.agentId) ?? []), ...event.ops]); - }); - - agents.add('sub-1'); - interactions.enqueue({ id: 'q1', kind: 'question', payload: { toolCallId: 'call_q1' }, tags: { agentId: 'sub-1', sessionId: 's1', turnId: 0 } }); - expect([...byAgent.keys()]).toEqual(['sub-1']); - binding.dispose(); - }); - - async function seedWireHomeWithTool(): Promise { - const home = await mkdtemp(join(tmpdir(), 'transcript-backfill-live-')); - const wireDir = join(home, 'sessions', 'ws', 's1', 'agents', 'main'); - await mkdir(wireDir, { recursive: true }); - const records = [ - { - type: 'context.append_message', - message: { - role: 'user', - content: [{ type: 'text', text: 'hi' }], - toolCalls: [], - origin: { kind: 'user' }, - }, - time: new Date().toISOString(), - }, - { - type: 'context.append_message', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'Hello ' }], - toolCalls: [{ type: 'function', id: 'call_1', name: 'Bash', arguments: '{"command":"ls"}' }], - }, - time: new Date().toISOString(), - }, - { - type: 'context.append_message', - message: { - role: 'tool', - content: [{ type: 'text', text: 'a.txt' }], - toolCallId: 'call_1', - toolCalls: [], - }, - time: new Date().toISOString(), - }, - ]; - await writeFile(join(wireDir, 'wire.jsonl'), `${records.map((r) => JSON.stringify(r)).join('\n')}\n`); - return home; - } - - async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise { - const deadline = Date.now() + timeoutMs; - while (!condition()) { - if (Date.now() > deadline) throw new Error('waitFor timed out'); - await new Promise((resolve) => setTimeout(resolve, 20)); - } - } - - it('subscribes the bus for an agent whose projector was seeded before its handle existed', () => { - const agents = new FakeAgents(); - interactions.enqueue({ id: 'q-sub', kind: 'question', payload: { toolCallId: 'call_sub' }, tags: { agentId: 'sub-1', sessionId: 's1', turnId: 0 } }); - const store = new TranscriptStore('s1'); - const byAgent = new Map(); - const binding = bindSessionTranscript(store, fakeSession(agents), undefined, (event) => { - byAgent.set(event.agentId, [...(byAgent.get(event.agentId) ?? []), ...event.ops]); - }); - - binding.seedPendingInteractions('sub-1'); - expect(byAgent.get('sub-1')?.map((op) => op.op)).toEqual(['interaction.upsert']); - - const sub = agents.add('sub-1'); - sub.bus.emit(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - expect(byAgent.get('sub-1')!.length).toBeGreaterThan(1); - binding.dispose(); - }); - - it('seeds active prompt identity before a late-bound turn ends', () => { - const agents = new FakeAgents(); - const main = agents.add('main', { - loopStatus: { state: 'running', activeTurnId: 0 }, - activePromptId: 'prompt-1', - }); - const store = new TranscriptStore('s1'); - const binding = bindSessionTranscript(store, fakeSession(agents)); - - main.bus.emit(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); - - expect(store.getAgent('main')?.getTurn('t0')).toMatchObject({ - state: 'completed', - triggerPromptId: 'prompt-1', - }); - binding.dispose(); - }); - - it('overlays the in-flight turn as running after a backfill', async () => { - const home = await seedWireHome(); - try { - const agents = new FakeAgents(); - agents.add('main', { - loopStatus: { state: 'running', activeTurnId: 0 }, - activePromptId: 'prompt-1', - }); - const service = new TranscriptService({ - homeDir: home, - core: fakeCoreWithAgents(agents), - }); - const store = service.forSessionLive('s1'); - await service.whenReady('s1'); - expect(store?.getAgent('main')?.getTurn('t0')).toMatchObject({ - state: 'running', - triggerPromptId: 'prompt-1', - prompt: 'hi', - }); - service.dropSession('s1'); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('merges the backfill live-first: live frame fields and longer text survive', async () => { - const home = await seedWireHomeWithTool(); - try { - const agents = new FakeAgents(); - agents.add('main', { loopStatus: { state: 'running', activeTurnId: 0 } }); - const service = new TranscriptService({ - homeDir: home, - core: fakeCoreWithAgents(agents), - }); - const store = service.forSessionLive('s1'); - const bus = agents.byId('main')!.bus; - bus.emit(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' }, prompt: 'hi' })); - bus.emit(ev({ type: 'turn.step.started', turnId: 0, step: 1 })); - bus.emit(ev({ type: 'assistant.delta', turnId: 0, delta: 'Hello world' })); - bus.emit( - ev({ - type: 'tool.call.started', - turnId: 0, - toolCallId: 'call_1', - name: 'Bash', - args: { command: 'ls' }, - display: { kind: 'command', command: 'ls' }, - }), - ); - await service.whenReady('s1'); - - const turn = store?.getAgent('main')?.getTurn('t0'); - expect(turn?.state).toBe('running'); - const text = turn?.steps[0]?.frames.find((f) => f.kind === 'text'); - expect(text).toMatchObject({ text: 'Hello world' }); - const tool = turn?.steps[0]?.frames.find((f) => f.kind === 'tool'); - expect(tool).toMatchObject({ - state: 'done', - output: 'a.txt', - display: { kind: 'command', command: 'ls' }, - }); - service.dropSession('s1'); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('re-asserts running when the backfill rebuilds the live turn completed', async () => { - const home = await seedWireHome(); - try { - const agents = new FakeAgents(); - agents.add('main', { loopStatus: { state: 'running', activeTurnId: 0 } }); - const service = new TranscriptService({ - homeDir: home, - core: fakeCoreWithAgents(agents), - }); - const store = service.forSessionLive('s1'); - agents - .byId('main')! - .bus.emit(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' }, prompt: 'live hi' })); - await service.whenReady('s1'); - expect(store?.getAgent('main')?.getTurn('t0')).toMatchObject({ - state: 'running', - prompt: 'live hi', - }); - service.dropSession('s1'); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('backfill + overlay keep the live attachment ids and drop the cold counterpart entity', async () => { - const home = await seedWireHome(SHOT_PNG_UPLOAD); - try { - const agents = new FakeAgents(); - agents.add('main', { loopStatus: { state: 'running', activeTurnId: 0 } }); - const service = new TranscriptService({ - homeDir: home, - core: fakeCoreWithAgents(agents), - }); - const store = service.forSessionLive('s1'); - agents.byId('main')!.bus.emit( - ev({ - type: 'turn.started', - turnId: 0, - origin: { kind: 'user' }, - prompt: 'live prompt', - promptAttachments: [{ kind: 'image', fileId: 'file_1' }], - }), - ); - await service.whenReady('s1'); - - const agent = store?.getAgent('main'); - expect(agent?.getTurn('t0')).toMatchObject({ - state: 'running', - prompt: 'live prompt', - attachmentIds: ['t0.att1'], - }); - expect(agent?.getAttachment('t0.att1')).toBeDefined(); - expect(agent?.getAttachment('att_1')).toBeUndefined(); - service.dropSession('s1'); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - it('post-turn heal keeps the live attachment ids and never upserts the cold counterparts', async () => { - const home = await seedWireHome(SHOT_PNG_UPLOAD); - try { - const agents = new FakeAgents(); - agents.add('main', { loopStatus: { state: 'idle' } }); - const service = new TranscriptService({ - homeDir: home, - core: fakeCoreWithAgents(agents), - }); - const store = service.forSessionLive('s1'); - const batches: TranscriptOperation[][] = []; - service.onSessionOps('s1', (event) => { - if (event.agentId === 'main') batches.push([...event.ops]); - }); - const bus = agents.byId('main')!.bus; - bus.emit( - ev({ - type: 'turn.started', - turnId: 0, - origin: { kind: 'user' }, - prompt: 'live prompt', - promptAttachments: [{ kind: 'image', fileId: 'file_1' }], - }), - ); - await service.whenReady('s1'); - - bus.emit(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); - await waitFor(() => - batches.some((batch) => batch.some((op) => op.op === 'step.upsert' && op.turnId === 't0')), - ); - const healBatch = batches.find((batch) => - batch.some((op) => op.op === 'step.upsert' && op.turnId === 't0'), - )!; - expect(healBatch.find((op) => op.op === 'turn.upsert')).toMatchObject({ - turn: { attachmentIds: ['t0.att1'] }, - }); - const attachmentUpserts = batches.flatMap((batch) => - batch.filter((op) => op.op === 'attachment.upsert'), - ); - expect(attachmentUpserts).toEqual([ - { op: 'attachment.upsert', attachment: expect.objectContaining({ attachmentId: 't0.att1' }) }, - ]); - - const agent = store?.getAgent('main'); - expect(agent?.getTurn('t0')).toMatchObject({ - state: 'completed', - attachmentIds: ['t0.att1'], - }); - expect(agent?.getAttachment('t0.att1')).toBeDefined(); - expect(agent?.getAttachment('att_1')).toBeUndefined(); - service.dropSession('s1'); - } finally { - await rm(home, { recursive: true, force: true }); - } - }); - - describe('op journal', () => { - it('assigns consecutive per-agent seqs and serves catch-up from the journal', async () => { - const agents = new FakeAgents(); - const main = agents.add('main'); - const service = new TranscriptService({ - homeDir: '/nonexistent-home', - core: fakeCoreWithAgents(agents), - }); - service.forSessionLive('s1'); - await service.whenReady('s1'); - const base = service.getSeqWatermark('s1', 'main'); - - const seen: number[] = []; - service.onSessionOps('s1', (_event, seq) => seen.push(seq)); - main.bus.emit(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' } })); - main.bus.emit(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' })); - - expect(seen).toEqual([base + 1, base + 2]); - expect(service.getSeqWatermark('s1', 'main')).toBe(base + 2); - - const catchup = service.getOpsSince('s1', 'main', base); - expect(catchup?.complete).toBe(true); - expect(catchup?.latestSeq).toBe(base + 2); - expect(catchup?.batches.map((batch) => batch.seq)).toEqual([base + 1, base + 2]); - - expect(service.getOpsSince('s1', 'main', base + 2)).toMatchObject({ - batches: [], - latestSeq: base + 2, - complete: true, - }); - expect(service.getOpsSince('s1', 'main', base + 3)?.complete).toBe(false); - - const sub = agents.add('sub-1'); - sub.bus.emit(ev({ type: 'turn.started', turnId: 0, origin: { kind: 'user' } })); - expect(service.getSeqWatermark('s1', 'sub-1')).toBe(1); - expect(service.getOpsSince('s1', 'sub-1', 0)?.batches.map((batch) => batch.seq)).toEqual([1]); - - expect(service.getSeqWatermark('s1', 'nope')).toBe(0); - expect(service.getOpsSince('nope-session', 'main', 0)).toBeUndefined(); - service.dropSession('s1'); - }); - - it('marks catch-up incomplete once the bounded journal evicts old batches', async () => { - const agents = new FakeAgents(); - const main = agents.add('main'); - const service = new TranscriptService({ - homeDir: '/nonexistent-home', - core: fakeCoreWithAgents(agents), - }); - service.forSessionLive('s1'); - await service.whenReady('s1'); - const base = service.getSeqWatermark('s1', 'main'); - - for (let turnId = 1; turnId <= TRANSCRIPT_OPS_JOURNAL_CAPACITY + 1; turnId++) { - main.bus.emit(ev({ type: 'turn.started', turnId, origin: { kind: 'user' } })); - } - const watermark = service.getSeqWatermark('s1', 'main'); - expect(watermark).toBe(base + TRANSCRIPT_OPS_JOURNAL_CAPACITY + 1); - - const evicted = service.getOpsSince('s1', 'main', base); - expect(evicted?.complete).toBe(false); - expect(evicted?.latestSeq).toBe(watermark); - expect(evicted?.batches).toHaveLength(TRANSCRIPT_OPS_JOURNAL_CAPACITY); - - const recent = service.getOpsSince('s1', 'main', watermark - 10); - expect(recent?.complete).toBe(true); - expect(recent?.batches.map((batch) => batch.seq)).toEqual( - Array.from({ length: 10 }, (_, i) => watermark - 9 + i), - ); - service.dropSession('s1'); - }); - }); -}); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts deleted file mode 100644 index 88a83865664..00000000000 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ /dev/null @@ -1,3074 +0,0 @@ -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import type { - AgentContext, - IScopeHandle, - Scope, - SessionActivityCause, - SessionActivityChangedEvent, - SessionActivityState, -} from '@moonshot-ai/agent-core-v2'; -import { - INTERACTION_TAG_SESSION_ID, - LifecycleScope, - IAgentLifecycleService, - IAgentLoopService, - IAgentProfileService, - IAgentScopeContext, - IEventBus, - IEventService, - IModelCatalog, - IModelService, - ISessionActivityView, - ISessionMetadata, - ISessionLifecycleService, - ISessionManager, - ISessionTokenCountingService, - ISessionUsageService, - IWorkspaceInstanceManager, - IWorkspaceSessions, - MAIN_AGENT_ID, - interactions, - makeAgentScopeContext, -} from '@moonshot-ai/agent-core-v2'; -import { TurnStarted } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; -import type { AgentActivitySnapshot } from '@moonshot-ai/agent-core-v2/agent/loop/loop'; -import type { AgentEvent } from '../src/transport/ws/v1/events'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { sessionEventMessageSchema } from '../src/protocol/ws-control'; -import { - type BroadcastDelivery, - type BroadcastTarget, - SessionEventBroadcaster, -} from '../src/transport/ws/v1/sessionEventBroadcaster'; -import { SessionEventJournal, type EventEnvelope } from '../src/transport/ws/v1/sessionEventJournal'; -import { TranscriptService } from '../src/services/transcript/transcriptService'; - -type FakeBusEvent = { type: string }; - -class FakeAgentBus { - private allHandlers: Array<(e: FakeBusEvent) => void> = []; - private perType = new Map void>>(); - subscribe(handler: (e: FakeBusEvent) => void): { dispose(): void }; - subscribe(type: string, handler: (e: FakeBusEvent) => void): { dispose(): void }; - subscribe(typeOrHandler: string | ((e: FakeBusEvent) => void), handler?: (e: FakeBusEvent) => void) { - if (typeof typeOrHandler === 'function') { - this.allHandlers.push(typeOrHandler); - return { - dispose: () => { - const i = this.allHandlers.indexOf(typeOrHandler); - if (i >= 0) this.allHandlers.splice(i, 1); - }, - }; - } - const list = this.perType.get(typeOrHandler) ?? []; - list.push(handler!); - this.perType.set(typeOrHandler, list); - return { - dispose: () => { - const i = list.indexOf(handler!); - if (i >= 0) list.splice(i, 1); - }, - }; - } - emit(e: FakeBusEvent): void { - for (const h of [...this.allHandlers]) h(e); - for (const h of [...(this.perType.get(e.type) ?? [])]) h(e); - } -} - -class FakeEventBus { - private handlers: Array<(e: { type: string; payload: unknown }) => void> = []; - subscribe(handler: (e: { type: string; payload: unknown }) => void) { - this.handlers.push(handler); - return { - dispose: () => { - const i = this.handlers.indexOf(handler); - if (i >= 0) this.handlers.splice(i, 1); - }, - }; - } - emit(e: { type: string; payload: unknown }): void { - for (const h of [...this.handlers]) h(e); - } -} - -class FakeAgentHandle { - readonly kind = LifecycleScope.Agent; - readonly bus = new FakeAgentBus(); - readonly accessor; - readonly context: AgentContext; - activity: AgentActivitySnapshot = {}; - private readonly services = new Map(); - constructor(readonly id: string) { - const scope = makeAgentScopeContext({ - agentId: id, - agentScope: `agents/${id}`, - generation: 1, - }); - this.context = scope.agentContext; - this.services.set(IAgentScopeContext, scope); - this.services.set(IEventBus, this.bus); - this.services.set(IAgentLoopService, { - status: () => ({ state: this.activity.turn === undefined ? 'idle' : 'running' }), - activitySnapshot: () => this.activity, - }); - this.accessor = { - get: (token: unknown) => this.services.get(token), - }; - } - set(token: unknown, service: unknown): void { - this.services.set(token, service); - } - dispose(): void {} -} - -class FakeLifecycle { - readonly handles: FakeAgentHandle[] = []; - readonly workView: FakeSessionActivityView; - - constructor(readonly sessionId = 's1') { - this.workView = new FakeSessionActivityView(this); - } - - private readonly turnCounters = new Map(); - private createHandlers: Array<(context: AgentContext) => void> = []; - private disposeHandlers: Array<(context: AgentContext) => void> = []; - list(): readonly AgentContext[] { - return this.handles.map((handle) => handle.context); - } - get(context: AgentContext): FakeAgentHandle | undefined { - return this.handles.find((h) => h.id === context.agentId); - } - getHandle(id: string): FakeAgentHandle | undefined { - return this.handles.find((h) => h.id === id); - } - handleOf(agentId: string): FakeAgentHandle | undefined { - return this.handles.find((h) => h.id === agentId); - } - onDidCreate(h: (context: AgentContext) => void) { - this.createHandlers.push(h); - return { dispose: () => {} }; - } - onDidClose(h: (context: AgentContext) => void) { - this.disposeHandlers.push(h); - return { dispose: () => {} }; - } - addAgent(id: string): FakeAgentHandle { - const handle = new FakeAgentHandle(id); - const onTurnStarted = handle.bus.subscribe((e) => { - if (e.type !== 'turn.started') return; - handle.activity = { - turn: { - turnId: (e as { turnId?: number }).turnId ?? 0, - phase: 'running', - step: 1, - ending: false, - activeToolCalls: [], - since: 0, - }, - }; - }); - const onTurnEnded = handle.bus.subscribe((e) => { - if (e.type !== 'turn.ended') return; - handle.activity = {}; - }); - this.turnCounters.set(id, { - dispose: () => { - onTurnStarted.dispose(); - onTurnEnded.dispose(); - }, - }); - this.handles.push(handle); - for (const cb of this.createHandlers) cb(handle.context); - return handle; - } - removeAgent(id: string): void { - const idx = this.handles.findIndex((h) => h.id === id); - const [removed] = idx >= 0 ? this.handles.splice(idx, 1) : []; - this.turnCounters.get(id)?.dispose(); - this.turnCounters.delete(id); - if (removed !== undefined) { - for (const cb of this.disposeHandlers) cb(removed.context); - } - } -} - -function mapReason(reason: string | undefined): 'completed' | 'cancelled' | 'failed' | undefined { - if (reason === undefined) return undefined; - return reason === 'completed' ? 'completed' : reason === 'cancelled' ? 'cancelled' : 'failed'; -} - -class FakeSessionActivityView { - private readonly listeners = new Set<(change: SessionActivityChangedEvent) => void>(); - private readonly folds = new Map< - string, - { turnActive: boolean; background: number; lastTurnReason?: 'completed' | 'cancelled' | 'failed' } - >(); - private readonly busSubscriptions = new Map(); - private readonly lifecycle: FakeLifecycle; - private current: SessionActivityState; - - constructor(lifecycle: FakeLifecycle) { - this.lifecycle = lifecycle; - for (const handle of lifecycle.list()) this.attach(handle as unknown as FakeAgentHandle); - lifecycle.onDidCreate((context) => { - const handle = lifecycle.get(context); - if (handle !== undefined) this.attach(handle as unknown as FakeAgentHandle); - this.recompute('agent_lifecycle'); - }); - lifecycle.onDidClose((context) => { - const agentId = context.agentId; - this.busSubscriptions.get(agentId)?.dispose(); - this.busSubscriptions.delete(agentId); - if (this.folds.delete(agentId)) this.recompute('agent_lifecycle'); - }); - interactions.onDidChangePending(() => this.recompute('interaction')); - this.current = this.aggregate(); - } - - state(): SessionActivityState { - return this.current; - } - - onDidChange(listener: (change: SessionActivityChangedEvent) => void): { dispose(): void } { - this.listeners.add(listener); - return { dispose: () => this.listeners.delete(listener) }; - } - - private attach(handle: FakeAgentHandle): void { - if (this.folds.has(handle.id)) return; - this.folds.set(handle.id, { - turnActive: handle.activity.turn !== undefined, - background: 0, - }); - const subscriptions = [ - handle.bus.subscribe('turn.started', () => - this.patchFold(handle.id, (f) => ({ - ...f, - turnActive: true, - lastTurnReason: handle.id === MAIN_AGENT_ID ? undefined : f.lastTurnReason, - })), - ), - handle.bus.subscribe('turn.ended', (e) => - this.patchFold(handle.id, (f) => ({ - ...f, - turnActive: false, - lastTurnReason: - handle.id === MAIN_AGENT_ID - ? mapReason((e as { reason?: string }).reason) - : f.lastTurnReason, - })), - ), - handle.bus.subscribe('task.started', () => - this.patchFold(handle.id, (f) => ({ ...f, background: f.background + 1 })), - ), - handle.bus.subscribe('task.terminated', () => - this.patchFold(handle.id, (f) => ({ ...f, background: f.background - 1 })), - ), - handle.bus.subscribe('compaction.started', () => - this.patchFold(handle.id, (f) => ({ ...f, background: f.background + 1 })), - ), - handle.bus.subscribe('compaction.completed', () => - this.patchFold(handle.id, (f) => ({ ...f, background: f.background - 1 })), - ), - handle.bus.subscribe('compaction.cancelled', () => - this.patchFold(handle.id, (f) => ({ ...f, background: f.background - 1 })), - ), - ]; - this.busSubscriptions.set(handle.id, { - dispose: () => subscriptions.forEach((s) => s.dispose()), - }); - } - - private patchFold( - agentId: string, - patch: (fold: { - turnActive: boolean; - background: number; - lastTurnReason?: 'completed' | 'cancelled' | 'failed'; - }) => { - turnActive: boolean; - background: number; - lastTurnReason?: 'completed' | 'cancelled' | 'failed'; - }, - ): void { - const previous = this.folds.get(agentId); - if (previous === undefined) return; - const next = patch(previous); - this.folds.set(agentId, next); - let cause: SessionActivityCause | undefined; - if (!previous.turnActive && next.turnActive) cause = 'turn_started'; - else if (previous.turnActive && !next.turnActive) cause = 'turn_ended'; - else if (previous.background !== next.background) cause = 'background'; - else if (agentId === MAIN_AGENT_ID && previous.lastTurnReason !== next.lastTurnReason) { - cause = 'turn_ended'; - } - if (cause !== undefined) this.recompute(cause); - } - - private recompute(cause: SessionActivityCause): void { - const next = this.aggregate(); - if ( - next.busy === this.current.busy && - next.mainTurnActive === this.current.mainTurnActive && - next.pendingInteraction === this.current.pendingInteraction && - next.lastTurnReason === this.current.lastTurnReason - ) { - return; - } - this.current = next; - for (const listener of [...this.listeners]) listener({ state: next, cause }); - } - - private aggregate(): SessionActivityState { - let busy = false; - for (const fold of this.folds.values()) { - if (fold.turnActive || fold.background > 0) { - busy = true; - break; - } - } - const pending = interactions.findAll({ - resolved: false, - tags: { [INTERACTION_TAG_SESSION_ID]: this.lifecycle.sessionId }, - }); - return { - busy, - mainTurnActive: this.folds.get(MAIN_AGENT_ID)?.turnActive ?? false, - pendingInteraction: pending.some((i) => i.kind === 'approval') - ? 'approval' - : pending.some((i) => i.kind === 'question') - ? 'question' - : 'none', - lastTurnReason: this.folds.get(MAIN_AGENT_ID)?.lastTurnReason, - }; - } -} - -function makeCore( - sessions: Map, - eventBus = new FakeEventBus(), - metaAgents: Record = {}, -): Scope { - const handles = new WeakMap(); - const sessionFor = (sid: string) => { - const lifecycle = sessions.get(sid); - if (lifecycle === undefined) return undefined; - const existing = handles.get(lifecycle); - if (existing !== undefined) return existing; - const sessionAccessor = { - get: (t: unknown) => { - if (t === IAgentLifecycleService) return lifecycle; - if (t === ISessionActivityView) return lifecycle.workView; - if (t === ISessionMetadata) return { read: async () => ({ agents: metaAgents }) }; - return undefined; - }, - }; - const handle = { id: sid, kind: LifecycleScope.Session, accessor: sessionAccessor, dispose: () => {} } as unknown as IScopeHandle; - handles.set(lifecycle, handle); - return handle; - }; - const sessionLifecycle = { - onDidCloseSession: () => ({ dispose: () => {} }), - onDidArchiveSession: () => ({ dispose: () => {} }), - get: sessionFor, - }; - const handler = { - id: 'wd', - kind: 'program', - accessor: { - get: (t: unknown) => (t === ISessionLifecycleService ? sessionLifecycle : undefined), - }, - dispose: () => {}, - }; - const accessor = { - get(token: unknown): unknown { - if (token === IEventService) return eventBus; - if (token === ISessionManager) { - return { - get: sessionFor, - list: () => [...sessions.keys()].map((sessionId) => sessionFor(sessionId)), - }; - } - if (token === IWorkspaceInstanceManager) { - return { - list: () => [{ program: { accessor: handler.accessor } }], - onDidChange: () => ({ dispose: () => {} }), - }; - } - if (token === IWorkspaceSessions) { - return { listRecent: async () => [], count: async () => 3 }; - } - return undefined; - }, - }; - return { accessor } as unknown as Scope; -} - -function agentEvent(type: string, extra: Record = {}): AgentEvent { - return { type, ...extra } as unknown as AgentEvent; -} - -function collectingTarget(): { - target: BroadcastTarget; - envelopes: EventEnvelope[]; - deliveries: BroadcastDelivery[]; -} { - const envelopes: EventEnvelope[] = []; - const deliveries: BroadcastDelivery[] = []; - return { - target: { - send: (envelope, delivery = 'subscription') => { - envelopes.push(envelope); - deliveries.push(delivery); - }, - }, - envelopes, - deliveries, - }; -} - -describe('SessionEventBroadcaster', () => { - let dir: string; - let sessions: Map; - let eventBus: FakeEventBus; - let bc: SessionEventBroadcaster; - - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'kimi-broadcaster-test-')); - sessions = new Map(); - eventBus = new FakeEventBus(); - bc = new SessionEventBroadcaster({ - eventsDir: dir, - core: makeCore(sessions, eventBus), - maxBufferSize: 3, - }); - }); - - afterEach(async () => { - await bc.close(); - for (const sessionId of sessions.keys()) interactions.purgeSession(sessionId); - await rm(dir, { recursive: true, force: true }); - }); - - it('does not install a pending state after its session disappears', async () => { - const lifecycle = new FakeLifecycle(); - lifecycle.addAgent('main'); - sessions.set('s1', lifecycle); - const journal = await SessionEventJournal.open(join(dir, 's1.jsonl')); - let enter!: () => void; - let release!: () => void; - const entered = new Promise((resolve) => { enter = resolve; }); - const gate = new Promise((resolve) => { release = resolve; }); - const opening = vi.spyOn(SessionEventJournal, 'open').mockImplementation(async () => { - enter(); - await gate; - return journal; - }); - try { - const subscription = bc.subscribe('s1', collectingTarget().target); - await entered; - sessions.delete('s1'); - release(); - expect(await subscription).toBe(false); - expect(await bc.subscribe('s1', collectingTarget().target)).toBe(false); - } finally { - release(); - opening.mockRestore(); - } - }); - - it('preserves a real Event2 time in payload and derives the envelope timestamp from it', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - const event = new TurnStarted( - { agentId: 'main', turnId: 1, origin: { kind: 'user' } }, - 1_700_000_000_123, - ); - - main.bus.emit(event); - await bc.getCursor('s1'); - - const envelope = envelopes.find((candidate) => candidate.type === 'turn.started'); - expect(envelope?.timestamp).toBe(new Date(event.time).toISOString()); - expect(envelope?.payload).toMatchObject({ - type: 'turn.started', - time: event.time, - turnId: 1, - origin: { kind: 'user' }, - agentId: 'main', - sessionId: 's1', - }); - }); - - it('stamps monotonic seq on durable events and fans out', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - - const { target, envelopes, deliveries } = collectingTarget(); - expect(await bc.subscribe('s1', target)).toBe(true); - - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - await bc.getCursor('s1'); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - await bc.getCursor('s1'); - - const durable = envelopes.filter((e) => e.volatile !== true); - expect(durable.map((e) => e.seq)).toEqual([1, 2, 3, 4]); - expect(durable[1]).toMatchObject({ - type: 'event.session.work_changed', - payload: { busy: true, last_turn_reason: undefined, agentId: 'main', sessionId: 's1' }, - }); - expect(durable[3]).toMatchObject({ - type: 'event.session.work_changed', - payload: { busy: false, last_turn_reason: 'completed' }, - }); - expect(envelopes.every((e) => e.epoch === envelopes[0]!.epoch)).toBe(true); - expect(durable[1]!.volatile).toBeUndefined(); - expect( - envelopes.flatMap((envelope, index) => - envelope.volatile === true ? [] : [[envelope.type, deliveries[index]]], - ), - ).toEqual([ - ['turn.started', 'subscription'], - ['event.session.work_changed', 'immediate'], - ['turn.ended', 'subscription'], - ['event.session.work_changed', 'immediate'], - ]); - }); - - it('fans out volatile events with the current watermark + offset, not journaled', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'Hi' })); - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: ' there' })); - await bc.getCursor('s1'); - - const vol = envelopes.filter((e) => e.volatile === true && e.type === 'assistant.delta'); - expect(vol).toHaveLength(2); - expect(vol.every((e) => e.seq === 2)).toBe(true); - expect(vol.map((e) => e.offset)).toEqual([0, 2]); - expect((await bc.getCursor('s1')).seq).toBe(2); - }); - - it('projects main-agent status and context changes into complete v1 status events', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - let contextSize = 10; - const usage = { - total: { inputOther: 1, output: 2, inputCacheRead: 0, inputCacheCreation: 0 }, - }; - main.set(ISessionTokenCountingService, { - statusSize: () => contextSize, - }); - main.set(IAgentProfileService, { - getModel: () => 'example-model', - getModelCapabilities: () => ({ max_context_tokens: 128_000 }), - }); - main.set(ISessionUsageService, { status: () => usage }); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('agent.status.updated', { usage })); - contextSize = 20; - main.bus.emit(agentEvent('context.spliced', { start: 0, deleteCount: 0, messages: [] })); - main.bus.emit(agentEvent('context.spliced', { start: 0, deleteCount: 0, messages: [] })); - await bc.getCursor('s1'); - - const statuses = envelopes.filter((envelope) => envelope.type === 'agent.status.updated'); - expect(statuses).toHaveLength(2); - expect(statuses.map((envelope) => envelope.payload)).toMatchObject([ - { - type: 'agent.status.updated', - usage, - contextTokens: 10, - maxContextTokens: 128_000, - model: 'example-model', - }, - { - type: 'agent.status.updated', - usage, - contextTokens: 20, - maxContextTokens: 128_000, - model: 'example-model', - }, - ]); - }); - - it('folds the legacy status snapshot into subagent status events too', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - const sub = lc.addAgent('agent-1'); - const usage = { - total: { inputOther: 1, output: 2, inputCacheRead: 0, inputCacheCreation: 0 }, - }; - sub.set(ISessionTokenCountingService, { statusSize: () => 10 }); - sub.set(IAgentProfileService, { - getModel: () => 'sub-model', - getModelCapabilities: () => ({ max_context_tokens: 128_000 }), - }); - sub.set(ISessionUsageService, { status: () => usage }); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - sub.bus.emit(agentEvent('agent.status.updated', { usage })); - await bc.getCursor('s1'); - - const statuses = envelopes.filter((envelope) => envelope.type === 'agent.status.updated'); - expect(statuses).toHaveLength(1); - expect(statuses[0]!.payload).toMatchObject({ - type: 'agent.status.updated', - agentId: 'agent-1', - usage, - contextTokens: 10, - maxContextTokens: 128_000, - model: 'sub-model', - }); - }); - - it('publishes the input cap as the status context limit when declared', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - const usage = { - byModel: { - 'example-model': { inputOther: 1, output: 2, inputCacheRead: 0, inputCacheCreation: 0 }, - }, - total: { inputOther: 1, output: 2, inputCacheRead: 0, inputCacheCreation: 0 }, - }; - main.set(ISessionTokenCountingService, { statusSize: () => 10 }); - main.set(IAgentProfileService, { - getModel: () => 'example-model', - getModelCapabilities: () => ({ max_context_tokens: 128_000, max_input_tokens: 64_000 }), - }); - main.set(ISessionUsageService, { status: () => usage }); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('agent.status.updated', { usage })); - await bc.getCursor('s1'); - - const statuses = envelopes.filter((envelope) => envelope.type === 'agent.status.updated'); - expect(statuses.map((envelope) => envelope.payload)).toMatchObject([ - { type: 'agent.status.updated', maxContextTokens: 64_000 }, - ]); - }); - - it('omits maxContextTokens instead of pushing 0 when the context limit is unknown', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - main.set(ISessionTokenCountingService, { statusSize: () => 10 }); - main.set(IAgentProfileService, { - getModel: () => 'ghost-model', - getModelCapabilities: () => ({ max_context_tokens: 0 }), - }); - main.set(ISessionUsageService, { status: () => ({}) }); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('agent.status.updated', {})); - await bc.getCursor('s1'); - - const statuses = envelopes.filter((envelope) => envelope.type === 'agent.status.updated'); - expect(statuses).toHaveLength(1); - const payload = statuses[0]!.payload as Record; - expect(payload['maxContextTokens']).toBeUndefined(); - expect(JSON.stringify(payload)).not.toContain('maxContextTokens'); - }); - - it('falls back to the default model limit when no model is bound', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - main.set(ISessionTokenCountingService, { statusSize: () => 10 }); - main.set(IAgentProfileService, { - getModel: () => '', - getModelCapabilities: () => ({ max_context_tokens: 0 }), - }); - main.set(ISessionUsageService, { status: () => ({}) }); - main.set(IModelService, { getDefaultModel: () => 'default-model' }); - main.set(IModelCatalog, { - get: (id: string) => { - expect(id).toBe('default-model'); - return { capabilities: { max_context_tokens: 200_000 } }; - }, - }); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('agent.status.updated', {})); - await bc.getCursor('s1'); - - const statuses = envelopes.filter((envelope) => envelope.type === 'agent.status.updated'); - expect(statuses).toHaveLength(1); - expect(statuses[0]!.payload).toMatchObject({ maxContextTokens: 200_000, model: '' }); - }); - - it('omits maxContextTokens when no model is bound and no default model resolves', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - main.set(ISessionTokenCountingService, { statusSize: () => 10 }); - main.set(IAgentProfileService, { - getModel: () => '', - getModelCapabilities: () => ({ max_context_tokens: 0 }), - }); - main.set(ISessionUsageService, { status: () => ({}) }); - main.set(IModelService, { getDefaultModel: () => 'removed-model' }); - main.set(IModelCatalog, { - get: () => { - throw new Error('unknown model'); - }, - }); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('agent.status.updated', {})); - await bc.getCursor('s1'); - - const statuses = envelopes.filter((envelope) => envelope.type === 'agent.status.updated'); - expect(statuses).toHaveLength(1); - expect(JSON.stringify(statuses[0]!.payload)).not.toContain('maxContextTokens'); - }); - - it('projects agent activity state into legacy running and ended phases', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - await bc.getCursor('s1'); - - const statuses = envelopes.filter((envelope) => envelope.type === 'agent.status.updated'); - expect(statuses.map((envelope) => envelope.payload)).toMatchObject([ - { phase: { kind: 'running', turnId: 1, step: 1 } }, - { phase: { kind: 'ended', turnId: 1, reason: 'completed' } }, - ]); - }); - - it('replays durable events since a cursor from the journal', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - await bc.getCursor('s1'); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - await bc.getCursor('s1'); - - const result = await bc.getBufferedSince('s1', { seq: 1 }); - expect(result.resyncRequired).toBe(false); - expect(result.events.map((e) => e.seq)).toEqual([2, 3, 4]); - expect(result.currentSeq).toBe(4); - }); - - it('returns buffer_overflow when the gap exceeds the cap', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target } = collectingTarget(); - await bc.subscribe('s1', target); - - for (let i = 0; i < 5; i++) main.bus.emit(agentEvent('turn.started', { turnId: i })); - await bc.getCursor('s1'); - - const result = await bc.getBufferedSince('s1', { seq: 0 }); - expect(result.resyncRequired).toBe('buffer_overflow'); - expect(result.currentSeq).toBe(6); - }); - - const TURN_STARTED_WIRE_KEYS = ['agentId', 'origin', 'prompt', 'sessionId', 'turnId', 'type']; - - it('forwards the promptId echo on a prompt-opened turn.started (live)', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit( - agentEvent('turn.started', { - turnId: 1, - origin: { kind: 'user' }, - prompt: 'with an exact id', - promptId: 'submission-1', - }), - ); - await bc.getCursor('s1'); - - const live = envelopes.find((e) => e.type === 'turn.started'); - expect(live).toBeDefined(); - expect(live!.payload).toHaveProperty('promptId', 'submission-1'); - expect(Object.keys(live!.payload as Record).toSorted()).toEqual( - [...TURN_STARTED_WIRE_KEYS, 'promptId'].toSorted(), - ); - }); - - it('keeps a video-prompt turn.started wire payload at the pre-attachment field set (live + disk-journal replay)', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit( - agentEvent('turn.started', { - turnId: 1, - origin: { kind: 'user' }, - prompt: 'summarize this clip', - promptAttachments: [{ kind: 'video', fileId: 'file_vid_1' }], - }), - ); - await bc.getCursor('s1'); - - const live = envelopes.find( - (e) => e.type === 'turn.started' && (e.payload as { turnId?: number }).turnId === 1, - ); - expect(live).toBeDefined(); - expect(live!.payload).not.toHaveProperty('promptAttachments'); - expect(Object.keys(live!.payload as Record).toSorted()).toEqual( - TURN_STARTED_WIRE_KEYS, - ); - - await bc.close(); - bc = new SessionEventBroadcaster({ - eventsDir: dir, - core: makeCore(sessions, eventBus), - maxBufferSize: 20, - }); - const replay = await bc.getBufferedSince('s1', { seq: 0 }); - expect(replay.resyncRequired).toBe(false); - const replayed = replay.events.find( - (e) => - e.envelope.type === 'turn.started' && - (e.envelope.payload as { turnId?: number }).turnId === 1, - ); - expect(replayed).toBeDefined(); - expect(replayed!.envelope.payload).not.toHaveProperty('promptAttachments'); - expect(Object.keys(replayed!.envelope.payload as Record).toSorted()).toEqual( - TURN_STARTED_WIRE_KEYS, - ); - }); - - it.each(['prompt.steered', 'prompt.queued', 'prompt.submitted'])( - 'projects %s content without leaking daemon refs (live + tail replay)', - async (type) => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - const ids = - type === 'prompt.steered' - ? { activePromptId: 'p1', promptIds: ['p2'], steeredAt: '2026-01-01T00:00:02.000Z' } - : type === 'prompt.submitted' - ? { promptId: 'p2', userMessageId: 'p2', status: 'queued', createdAt: '2026-01-01T00:00:01.000Z' } - : { promptId: 'p2', queueLength: 1 }; - main.bus.emit( - agentEvent(type, { - ...ids, - content: [ - { type: 'text', text: 'look at this' }, - { - type: 'image_url', - imageUrl: { url: 'kimi-file://f_img1?path=%2Fabs%2Fsession%2Fmedia%2Ff_img1.png' }, - }, - ], - }), - ); - await bc.getCursor('s1'); - - const expected = [ - { type: 'text', text: 'look at this' }, - { type: 'image', source: { kind: 'session_media', file_id: 'f_img1' } }, - ]; - const live = envelopes.find((e) => e.type === type); - expect(live).toBeDefined(); - expect((live!.payload as { content: unknown }).content).toEqual(expected); - expect(JSON.stringify(live!.payload)).not.toContain('kimi-file://'); - expect(JSON.stringify(live!.payload)).not.toContain('/abs/session'); - - const replay = await bc.getBufferedSince('s1', { seq: 0 }); - const replayed = replay.events.find((e) => e.envelope.type === type); - expect(replayed).toBeDefined(); - expect((replayed!.envelope.payload as { content: unknown }).content).toEqual(expected); - }, - ); - - it('returns epoch_changed for a mismatched epoch', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - const { target } = collectingTarget(); - await bc.subscribe('s1', target); - - const result = await bc.getBufferedSince('s1', { seq: 0, epoch: 'ep_wrong' }); - expect(result.resyncRequired).toBe('epoch_changed'); - }); - - it('subscribes to agents created after activation (onDidCreate)', async () => { - const lc = new FakeLifecycle(); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - const late = lc.addAgent('main'); - await bc.getCursor('s1'); - late.bus.emit(agentEvent('turn.started', { turnId: 7 })); - await bc.getCursor('s1'); - - expect(envelopes.filter((e) => e.volatile !== true).map((e) => e.seq)).toEqual([1, 2, 3]); - expect(envelopes[0]).toMatchObject({ type: 'agent.created' }); - expect((envelopes[0]!.payload as { agentId: string }).agentId).toBe('main'); - }); - - it('broadcasts agent.disposed only for agents this state attached', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - lc.addAgent('agent-0'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - lc.removeAgent('agent-0'); - lc.removeAgent('ghost'); - await bc.getCursor('s1'); - - const disposed = envelopes.filter((e) => e.type === 'agent.disposed'); - expect(disposed).toHaveLength(1); - expect((disposed[0]!.payload as { agentId: string }).agentId).toBe('agent-0'); - expect(disposed[0]!.volatile).toBeUndefined(); - }); - - it('delivers lifecycle events past the agent allowlist (session-grained)', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target, new Set(['main'])); - - lc.addAgent('agent-0'); - lc.removeAgent('agent-0'); - await bc.getCursor('s1'); - - const types = envelopes.map((e) => e.type); - expect(types).toContain('agent.created'); - expect(types).toContain('agent.disposed'); - }); - - it('journals lifecycle events for replay', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - const { target } = collectingTarget(); - await bc.subscribe('s1', target); - - lc.addAgent('agent-0'); - lc.removeAgent('agent-0'); - await bc.getCursor('s1'); - - const result = await bc.getBufferedSince('s1', { seq: 0 }); - expect(result.resyncRequired).toBe(false); - expect(result.events.map((e) => e.envelope.type)).toEqual([ - 'agent.created', - 'agent.disposed', - ]); - }); - - it('getSnapshotState returns the in-flight turn', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - await bc.subscribe('s1', collectingTarget().target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'Hello' })); - const snap = await bc.getSnapshotState('s1'); - - expect(snap.seq).toBe(2); - expect(snap.inFlightTurn).toMatchObject({ turn_id: 1, assistant_text: 'Hello' }); - }); - - it('getSnapshotState returns the live subagent roster until the next main turn starts', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - const sub = lc.addAgent('agent-1'); - sessions.set('s1', lc); - await bc.subscribe('s1', collectingTarget().target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - main.bus.emit( - agentEvent('subagent.spawned', { - subagentId: 'agent-1', - subagentName: 'kimi-subagent', - parentToolCallId: 'tc_swarm_1', - description: 'task agent-1', - swarmIndex: 0, - runInBackground: false, - model: 'provider/secondary', - thinkingEffort: 'low', - }), - ); - main.bus.emit(agentEvent('subagent.started', { subagentId: 'agent-1' })); - - const mid = await bc.getSnapshotState('s1'); - expect(mid.subagents).toEqual([ - expect.objectContaining({ - id: 'agent-1', - kind: 'subagent', - description: 'task agent-1', - subagent_phase: 'working', - parent_tool_call_id: 'tc_swarm_1', - swarm_index: 0, - run_in_background: false, - model: 'provider/secondary', - thinking_effort: 'low', - }), - ]); - - sub.bus.emit(agentEvent('turn.ended', { turnId: 2 })); - const still = await bc.getSnapshotState('s1'); - expect(still.subagents).toHaveLength(1); - - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - const ended = await bc.getSnapshotState('s1'); - expect(ended.subagents).toHaveLength(1); - - main.bus.emit(agentEvent('turn.started', { turnId: 2 })); - const next = await bc.getSnapshotState('s1'); - expect(next.subagents).toEqual([]); - }); - - it('subscribe returns false for an unknown session', async () => { - const { target } = collectingTarget(); - expect(await bc.subscribe('nope', target)).toBe(false); - }); - - it('broadcasts session.meta.updated under the real session id and fans out to every connection', async () => { - sessions.set('s1', new FakeLifecycle()); - - sessions.set('s2', new FakeLifecycle()); - - const s1View = collectingTarget(); - const s2View = collectingTarget(); - await bc.subscribe('s1', s1View.target); - await bc.subscribe('s2', s2View.target); - - eventBus.emit({ - type: 'session.meta.updated', - payload: { - agentId: 'main', - sessionId: 's1', - title: '测试', - patch: { title: '测试', isCustomTitle: false, lastPrompt: '测试' }, - }, - }); - - await vi.waitFor(() => expect(s1View.envelopes).toHaveLength(1)); - await vi.waitFor(() => expect(s2View.envelopes).toHaveLength(1)); - - expect(s1View.envelopes[0]).toMatchObject({ - type: 'session.meta.updated', - session_id: 's1', - payload: { - type: 'session.meta.updated', - agentId: 'main', - sessionId: 's1', - title: '测试', - patch: { title: '测试', lastPrompt: '测试' }, - }, - }); - expect(s1View.envelopes[0]!.session_id).not.toBe('__global__'); - expect(s2View.envelopes[0]!.session_id).toBe('s1'); - expect(s1View.envelopes[0]!.volatile).toBeUndefined(); - }); - - it('broadcasts event.session.created under the real session id and fans out to every connection', async () => { - sessions.set('s1', new FakeLifecycle()); - sessions.set('s2', new FakeLifecycle()); - - const s1View = collectingTarget(); - const s2View = collectingTarget(); - await bc.subscribe('s1', s1View.target); - await bc.subscribe('s2', s2View.target); - - const session = { id: 's1', title: 't', status: 'idle' }; - eventBus.emit({ - type: 'event.session.created', - payload: { agentId: 'main', sessionId: 's1', session }, - }); - - await vi.waitFor(() => expect(s1View.envelopes).toHaveLength(1)); - await vi.waitFor(() => expect(s2View.envelopes).toHaveLength(1)); - - expect(s1View.envelopes[0]).toMatchObject({ - type: 'event.session.created', - session_id: 's1', - payload: { - type: 'event.session.created', - agentId: 'main', - sessionId: 's1', - session, - }, - }); - expect(s1View.envelopes[0]!.session_id).not.toBe('__global__'); - expect(s2View.envelopes[0]!.session_id).toBe('s1'); - expect(s1View.envelopes[0]!.volatile).toBeUndefined(); - }); - - it('fans out event.session.archived to every connection, including for cold sessions', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - eventBus.emit({ - type: 'event.session.archived', - payload: { sessionId: 'cold-1', workspaceId: 'wd_cold' }, - }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.session.archived', - session_id: '__global__', - payload: { - type: 'event.session.archived', - agentId: 'main', - sessionId: 'cold-1', - workspace_id: 'wd_cold', - }, - }); - expect(globalView.deliveries).toEqual(['immediate']); - }); - - it('fans out event.session.deleted to every connection, including for cold sessions', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - eventBus.emit({ - type: 'event.session.deleted', - payload: { sessionId: 'cold-1', workspaceId: 'wd_cold' }, - }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.session.deleted', - session_id: '__global__', - payload: { - type: 'event.session.deleted', - agentId: 'main', - sessionId: 'cold-1', - workspace_id: 'wd_cold', - }, - }); - expect(globalView.deliveries).toEqual(['immediate']); - }); - - it('fans out event.workspace.created/updated with the wire workspace shape', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - const workspace = { - id: 'wd_a', - root: '/repo/a', - name: 'repo-a', - createdAt: 1_000, - lastOpenedAt: 2_000, - }; - eventBus.emit({ type: 'event.workspace.created', payload: { workspace } }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.workspace.created', - session_id: '__global__', - payload: { - type: 'event.workspace.created', - agentId: 'main', - sessionId: '__global__', - workspace: { - id: 'wd_a', - root: '/repo/a', - name: 'repo-a', - created_at: new Date(1_000).toISOString(), - last_opened_at: new Date(2_000).toISOString(), - session_count: 3, - }, - }, - }); - - eventBus.emit({ - type: 'event.workspace.updated', - payload: { workspace: { ...workspace, name: 'renamed' } }, - }); - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(2)); - expect(globalView.envelopes[1]).toMatchObject({ - type: 'event.workspace.updated', - payload: { - type: 'event.workspace.updated', - workspace: { id: 'wd_a', name: 'renamed', session_count: 3 }, - }, - }); - expect(globalView.deliveries).toEqual(['immediate', 'immediate']); - }); - - it('fans out event.workspace.deleted with the workspace id and root', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - eventBus.emit({ - type: 'event.workspace.deleted', - payload: { workspaceId: 'wd_a', root: '/repo/a' }, - }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.workspace.deleted', - session_id: '__global__', - payload: { - type: 'event.workspace.deleted', - agentId: 'main', - sessionId: '__global__', - workspace_id: 'wd_a', - root: '/repo/a', - }, - }); - - const journalPath = join(dir, '__global__.jsonl'); - await vi.waitFor(async () => { - expect(await readFile(journalPath, 'utf8').catch(() => '')).toContain('wd_a'); - }); - const before = await readFile(journalPath, 'utf8'); - eventBus.emit({ - type: 'event.workspace.deleted', - payload: { workspaceId: 'wd_b', root: '/repo/b' }, - }); - await bc.close(); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(await readFile(journalPath, 'utf8')).toBe(before); - }); - - it('gates event.di.unit_changed to connections opted into the DI debug feed', async () => { - const plainView = collectingTarget(); - bc.addGlobalTarget(plainView.target); - const diView = collectingTarget(); - bc.addGlobalTarget(diView.target); - bc.addDiEventTarget(diView.target); - - eventBus.emit({ - type: 'event.di.unit_changed', - payload: { scope: 'app', token: 'debugCascadeService', state: 'Active' }, - }); - - await vi.waitFor(() => expect(diView.envelopes).toHaveLength(1)); - expect(diView.envelopes[0]).toMatchObject({ - type: 'event.di.unit_changed', - session_id: '__global__', - volatile: true, - payload: { - type: 'event.di.unit_changed', - scope: 'app', - token: 'debugCascadeService', - state: 'Active', - agentId: 'main', - sessionId: '__global__', - }, - }); - expect(diView.deliveries).toEqual(['immediate']); - expect(plainView.envelopes).toHaveLength(0); - - eventBus.emit({ - type: 'event.di.unit_changed', - payload: { scope: 'app', token: 'x', state: 'Exploded' }, - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(diView.envelopes).toHaveLength(1); - - bc.removeGlobalTarget(diView.target); - eventBus.emit({ - type: 'event.di.unit_changed', - payload: { scope: 'app', token: 'debugCascadeService', state: 'Unloading' }, - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(diView.envelopes).toHaveLength(1); - expect(plainView.envelopes).toHaveLength(0); - }); - - describe('global fan-out to unsubscribed connections', () => { - it('delivers event.session.created to a global-only target that never subscribed', async () => { - sessions.set('s1', new FakeLifecycle()); - - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - const session = { id: 's1', title: 't', status: 'idle' }; - eventBus.emit({ - type: 'event.session.created', - payload: { agentId: 'main', sessionId: 's1', session }, - }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.session.created', - session_id: 's1', - }); - expect(globalView.deliveries).toEqual(['immediate']); - }); - - it('delivers work_changed to a global-only target while a subscriber drives the session', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - const { target } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - await bc.getCursor('s1'); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - await bc.getCursor('s1'); - - const workChanged = globalView.envelopes.filter( - (e) => e.type === 'event.session.work_changed', - ); - expect(workChanged).toHaveLength(2); - expect(workChanged[0]).toMatchObject({ session_id: 's1', payload: { busy: true } }); - expect(workChanged[1]).toMatchObject({ - session_id: 's1', - payload: { busy: false, last_turn_reason: 'completed' }, - }); - expect( - globalView.envelopes.filter((e) => e.type === 'turn.started'), - ).toHaveLength(0); - }); - - it('stops delivering after removeGlobalTarget', async () => { - sessions.set('s1', new FakeLifecycle()); - - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - bc.removeGlobalTarget(globalView.target); - - eventBus.emit({ - type: 'event.session.created', - payload: { agentId: 'main', sessionId: 's1', session: { id: 's1' } }, - }); - await bc.getCursor('s1'); - - expect(globalView.envelopes).toHaveLength(0); - }); - - it('delivers exactly one copy to a target that is both global and subscribed', async () => { - sessions.set('s1', new FakeLifecycle()); - - const both = collectingTarget(); - bc.addGlobalTarget(both.target); - await bc.subscribe('s1', both.target); - - eventBus.emit({ - type: 'event.session.created', - payload: { agentId: 'main', sessionId: 's1', session: { id: 's1' } }, - }); - - await vi.waitFor(() => expect(both.envelopes).toHaveLength(1)); - await bc.getCursor('s1'); - expect(both.envelopes).toHaveLength(1); - }); - - it('delivers event.config.warning to a global-only target that never subscribed', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - const warnings = [ - { - domain: 'loopControl', - message: - "[loop_control] 'max_retries_per_step' is deprecated and no longer used; rename it to 'max_attempts_per_step'.", - }, - { message: 'Environment variable OLD_VAR is deprecated; use NEW_VAR instead.' }, - ]; - eventBus.emit({ type: 'event.config.warning', payload: { warnings } }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.config.warning', - session_id: '__global__', - payload: { warnings }, - }); - expect(globalView.deliveries).toEqual(['immediate']); - }); - - it('fans out event.plugin.changed and event.capability.changed to global targets', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - eventBus.emit({ type: 'event.plugin.changed', payload: {} }); - eventBus.emit({ - type: 'event.capability.changed', - payload: { - capability_id: 'kimi-webbridge', - install: { running: true, step: 'download', percent: 42 }, - }, - }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(2)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.plugin.changed', - session_id: '__global__', - }); - expect(globalView.envelopes[1]).toMatchObject({ - type: 'event.capability.changed', - session_id: '__global__', - payload: { - capability_id: 'kimi-webbridge', - install: { running: true, step: 'download', percent: 42 }, - }, - }); - expect(globalView.envelopes[0]!.volatile).toBeUndefined(); - expect(globalView.envelopes[1]!.volatile).toBe(true); - }); - - it('drops malformed event.capability.changed payloads', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - eventBus.emit({ type: 'event.capability.changed', payload: null }); - eventBus.emit({ - type: 'event.capability.changed', - payload: { capability_id: 7, install: { running: true } }, - }); - eventBus.emit({ - type: 'event.capability.changed', - payload: { capability_id: 'kimi-cu' }, - }); - - eventBus.emit({ - type: 'event.capability.changed', - payload: { capability_id: 'kimi-cu', install: { running: false } }, - }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.capability.changed', - payload: { capability_id: 'kimi-cu', install: { running: false } }, - }); - }); - - it('drops malformed event.config.warning payloads', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - eventBus.emit({ type: 'event.config.warning', payload: { warnings: [{ message: 42 }] } }); - eventBus.emit({ type: 'event.config.warning', payload: { warnings: 'nope' } }); - eventBus.emit({ type: 'event.config.warning', payload: null }); - - const warnings = [{ message: 'something deprecated' }]; - eventBus.emit({ type: 'event.config.warning', payload: { warnings } }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.config.warning', - payload: { warnings }, - }); - }); - - it('delivers event.config.changed to a global-only target that never subscribed', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - const config = { default_model: 'k2', providers: {} }; - eventBus.emit({ - type: 'event.config.changed', - payload: { changedFields: ['defaultModel'], config }, - }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.config.changed', - session_id: '__global__', - payload: { changedFields: ['defaultModel'], config }, - }); - expect(globalView.deliveries).toEqual(['immediate']); - }); - - it('preserves unlisted config domains through event validation', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - const config = { default_model: 'k2', providers: {}, mcp: { servers: { fs: { command: 'npx' } } } }; - eventBus.emit({ - type: 'event.config.changed', - payload: { changedFields: ['mcp'], config }, - }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.config.changed', - session_id: '__global__', - payload: { changedFields: ['mcp'], config }, - }); - }); - - it('drops malformed event.config.changed payloads', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - eventBus.emit({ type: 'event.config.changed', payload: null }); - eventBus.emit({ type: 'event.config.changed', payload: { changedFields: 'defaultModel' } }); - eventBus.emit({ - type: 'event.config.changed', - payload: { changedFields: ['defaultModel', 7], config: {} }, - }); - eventBus.emit({ - type: 'event.config.changed', - payload: { changedFields: ['defaultModel'], config: null }, - }); - eventBus.emit({ - type: 'event.config.changed', - payload: { changedFields: ['defaultModel'], config: [] }, - }); - eventBus.emit({ - type: 'event.config.changed', - payload: { changedFields: [''], config: {} }, - }); - - eventBus.emit({ - type: 'event.config.changed', - payload: { changedFields: ['models'], config: { models: {} } }, - }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.config.changed', - payload: { changedFields: ['models'], config: { models: {} } }, - }); - }); - - it('delivers event.model_catalog.changed to a global-only target that never subscribed', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - const changed = [ - { provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 2, removed: 1 }, - ]; - const failed = [{ provider: 'managed:kimi-code', reason: 'network disabled' }]; - eventBus.emit({ - type: 'event.model_catalog.changed', - payload: { changed, unchanged: ['openai-main'], failed }, - }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.model_catalog.changed', - session_id: '__global__', - payload: { changed, unchanged: ['openai-main'], failed }, - }); - expect(globalView.deliveries).toEqual(['immediate']); - }); - - it('drops malformed event.model_catalog.changed payloads', async () => { - const globalView = collectingTarget(); - bc.addGlobalTarget(globalView.target); - - eventBus.emit({ type: 'event.model_catalog.changed', payload: null }); - eventBus.emit({ type: 'event.model_catalog.changed', payload: { changed: [] } }); - eventBus.emit({ - type: 'event.model_catalog.changed', - payload: { - changed: [{ provider_id: 'p', provider_name: 'P', added: '1', removed: 0 }], - unchanged: [], - failed: [], - }, - }); - eventBus.emit({ - type: 'event.model_catalog.changed', - payload: { - changed: [{ provider_id: 'p', provider_name: 'P', added: -1, removed: 0 }], - unchanged: [], - failed: [], - }, - }); - eventBus.emit({ - type: 'event.model_catalog.changed', - payload: { - changed: [{ provider_id: 'p', provider_name: 'P', added: 0.5, removed: 0 }], - unchanged: [], - failed: [], - }, - }); - eventBus.emit({ - type: 'event.model_catalog.changed', - payload: { - changed: [{ provider_id: '', provider_name: 'P', added: 1, removed: 0 }], - unchanged: [], - failed: [], - }, - }); - eventBus.emit({ - type: 'event.model_catalog.changed', - payload: { changed: [], unchanged: ['ok', 7], failed: [] }, - }); - eventBus.emit({ - type: 'event.model_catalog.changed', - payload: { changed: [], unchanged: ['ok', ''], failed: [] }, - }); - eventBus.emit({ - type: 'event.model_catalog.changed', - payload: { changed: [], unchanged: [], failed: [{ provider: 'p' }] }, - }); - eventBus.emit({ - type: 'event.model_catalog.changed', - payload: { changed: [], unchanged: [], failed: [{ provider: 'p', reason: '' }] }, - }); - - const changed = [ - { provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 1, removed: 0 }, - ]; - eventBus.emit({ - type: 'event.model_catalog.changed', - payload: { changed, unchanged: [], failed: [] }, - }); - - await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); - expect(globalView.envelopes[0]).toMatchObject({ - type: 'event.model_catalog.changed', - payload: { changed, unchanged: [], failed: [] }, - }); - }); - }); - - it('emits a durable event.session.work_changed(busy) trailing turn.started', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - await bc.getCursor('s1'); - - const durable = envelopes.filter((e) => e.volatile !== true); - expect(durable).toHaveLength(2); - expect(durable[0]).toMatchObject({ type: 'turn.started', seq: 1 }); - expect(durable[1]).toMatchObject({ - type: 'event.session.work_changed', - seq: 2, - session_id: 's1', - payload: { - type: 'event.session.work_changed', - busy: true, - last_turn_reason: undefined, - agentId: 'main', - sessionId: 's1', - }, - }); - expect(durable[1]!.volatile).toBeUndefined(); - }); - - it('emits a durable event.session.work_changed after turn.ended with the main turn outcome', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - await bc.getCursor('s1'); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - await bc.getCursor('s1'); - - const durable = envelopes.filter((e) => e.volatile !== true); - expect(durable).toHaveLength(4); - expect(durable[2]).toMatchObject({ type: 'turn.ended', seq: 3 }); - expect(durable[3]).toMatchObject({ - type: 'event.session.work_changed', - seq: 4, - session_id: 's1', - payload: { - type: 'event.session.work_changed', - busy: false, - last_turn_reason: 'completed', - agentId: 'main', - sessionId: 's1', - }, - }); - expect(durable[3]!.volatile).toBeUndefined(); - }); - - it('maps the main turn outcome into last_turn_reason on the post-turn work_changed', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - for (const [turnId, reason] of [ - [1, 'cancelled'], - [2, 'failed'], - [3, 'blocked'], - ] as const) { - main.bus.emit(agentEvent('turn.started', { turnId })); - await bc.getCursor('s1'); - main.bus.emit(agentEvent('turn.ended', { turnId, reason })); - await bc.getCursor('s1'); - } - - const durable = envelopes.filter((e) => e.volatile !== true); - expect(durable).toHaveLength(12); - const workChanged = durable.filter((e) => e.type === 'event.session.work_changed'); - expect(workChanged.map((e) => e.payload)).toMatchObject([ - { busy: true, last_turn_reason: undefined }, - { busy: false, last_turn_reason: 'cancelled' }, - { busy: true, last_turn_reason: undefined }, - { busy: false, last_turn_reason: 'failed' }, - { busy: true, last_turn_reason: undefined }, - { busy: false, last_turn_reason: 'failed' }, - ]); - }); - - it('flips busy from background tasks alone (no turn involved)', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit( - agentEvent('task.started', { - agentId: 'main', - info: { taskId: 'bash-1', kind: 'process', description: 'bash-1', status: 'running', startedAt: 100 }, - }), - ); - await bc.getCursor('s1'); - main.bus.emit(agentEvent('task.terminated', { agentId: 'main', taskId: 'bash-1' })); - await bc.getCursor('s1'); - - const workChanged = envelopes.filter((e) => e.type === 'event.session.work_changed'); - expect(workChanged.map((e) => e.payload)).toMatchObject([ - { busy: true, last_turn_reason: undefined }, - { busy: false, last_turn_reason: undefined }, - ]); - expect(envelopes.filter((e) => e.type === 'agent.status.updated')).toHaveLength(0); - }); - - it('emits the first background-work change from an agent created after activation', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - const late = lc.addAgent('agent-0'); - late.bus.emit( - agentEvent('task.started', { - agentId: 'agent-0', - info: { taskId: 'bash-1', kind: 'process', description: 'bash-1', status: 'running', startedAt: 100 }, - }), - ); - await bc.getCursor('s1'); - - const workChanged = envelopes.filter((event) => event.type === 'event.session.work_changed'); - expect(workChanged).toHaveLength(1); - expect(workChanged[0]?.payload).toMatchObject({ busy: true }); - }); - - it('reports the main turn ending while sub-agent background work keeps busy true', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - const sub = lc.addAgent('agent-0'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - sub.bus.emit( - agentEvent('task.started', { - agentId: 'agent-0', - info: { taskId: 'bash-1', kind: 'process', description: 'bash-1', status: 'running', startedAt: 100 }, - }), - ); - await bc.getCursor('s1'); - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - await bc.getCursor('s1'); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - await bc.getCursor('s1'); - - const workChanged = envelopes.filter((event) => event.type === 'event.session.work_changed'); - expect(workChanged.map((event) => event.payload)).toMatchObject([ - { busy: true, main_turn_active: false }, - { busy: true, main_turn_active: true }, - { busy: true, main_turn_active: false, last_turn_reason: 'completed' }, - ]); - }); - - it('flips busy but never touches last_turn_reason from sub-agent turn boundaries', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - const sub = lc.addAgent('agent-0'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - await bc.getCursor('s1'); - sub.bus.emit(agentEvent('turn.started', { turnId: 10 })); - await bc.getCursor('s1'); - sub.bus.emit(agentEvent('turn.ended', { turnId: 10, reason: 'completed' })); - await bc.getCursor('s1'); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - await bc.getCursor('s1'); - sub.bus.emit(agentEvent('turn.started', { turnId: 11 })); - await bc.getCursor('s1'); - sub.bus.emit(agentEvent('turn.ended', { turnId: 11, reason: 'cancelled' })); - await bc.getCursor('s1'); - - expect( - envelopes - .filter((e) => e.type === 'turn.started' || e.type === 'turn.ended') - .map((e) => (e.payload as { agentId: string }).agentId), - ).toEqual(['main', 'agent-0', 'agent-0', 'main', 'agent-0', 'agent-0']); - const workChanged = envelopes.filter((e) => e.type === 'event.session.work_changed'); - expect(workChanged.map((e) => e.payload)).toMatchObject([ - { busy: true, last_turn_reason: undefined }, - { busy: false, last_turn_reason: 'completed' }, - { busy: true, last_turn_reason: 'completed' }, - { busy: false, last_turn_reason: 'completed' }, - ]); - expect( - workChanged.every( - (e) => (e.payload as { last_turn_reason?: string }).last_turn_reason !== 'cancelled', - ), - ).toBe(true); - expect(envelopes.at(-1)!.type).toBe('event.session.work_changed'); - }); - - it('broadcasts question requested / answered as durable v1 events', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - interactions.enqueue({ - id: 'q1', - kind: 'question', - payload: { - toolCallId: 'call_1', - questions: [{ question: 'Pick one', options: [{ label: 'A' }, { label: 'B' }] }], - }, - tags: { sessionId: 's1' }, - }); - await bc.getCursor('s1'); - - expect(envelopes).toHaveLength(2); - expect(envelopes[0]).toMatchObject({ - type: 'event.session.work_changed', - seq: 1, - payload: { pending_interaction: 'question' }, - }); - expect(envelopes[1]).toMatchObject({ - type: 'event.question.requested', - seq: 2, - session_id: 's1', - payload: { - type: 'event.question.requested', - agentId: 'main', - sessionId: 's1', - question_id: 'q1', - session_id: 's1', - tool_call_id: 'call_1', - questions: [{ id: 'q_0', question: 'Pick one', options: [{ id: 'opt_0_0', label: 'A' }, { id: 'opt_0_1', label: 'B' }] }], - }, - }); - expect(envelopes[1]!.volatile).toBeUndefined(); - - interactions.respond('q1', { answers: { q_0: 'opt_0_0' }, method: 'enter' }); - await bc.getCursor('s1'); - - expect(envelopes).toHaveLength(4); - expect(envelopes[2]).toMatchObject({ - type: 'event.question.answered', - seq: 3, - session_id: 's1', - payload: { - question_id: 'q1', - answers: { q_0: 'opt_0_0' }, - }, - }); - expect((envelopes[2]!.payload as { resolved_at?: string }).resolved_at).toBeTypeOf('string'); - expect(envelopes[3]).toMatchObject({ - type: 'event.session.work_changed', - seq: 4, - payload: { pending_interaction: 'none' }, - }); - }); - - it('broadcasts question dismissed when resolved with null', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - interactions.enqueue({ - id: 'q1', - kind: 'question', - payload: { questions: [{ question: 'Pick', options: [{ label: 'A' }] }] }, - tags: { sessionId: 's1' }, - }); - interactions.respond('q1', null); - await bc.getCursor('s1'); - - expect(envelopes.map((e) => e.type)).toEqual([ - 'event.session.work_changed', - 'event.question.requested', - 'event.question.dismissed', - 'event.session.work_changed', - ]); - expect(envelopes[0]!.payload).toMatchObject({ pending_interaction: 'question' }); - expect(envelopes[2]!.payload).toMatchObject({ question_id: 'q1' }); - expect((envelopes[2]!.payload as { dismissed_at?: string }).dismissed_at).toBeTypeOf('string'); - expect(envelopes[3]!.payload).toMatchObject({ pending_interaction: 'none' }); - }); - - it('carries the requesting agent onto resolved interaction events', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - lc.addAgent('sub-1'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - interactions.enqueue({ - id: 'q-sub', - kind: 'question', - payload: { - toolCallId: 'call_q', - questions: [{ question: 'Pick', options: [{ label: 'A' }] }], - }, - tags: { agentId: 'sub-1', sessionId: 's1' }, - }); - await bc.getCursor('s1'); - expect( - envelopes.find((e) => e.type === 'event.question.requested')?.payload, - ).toMatchObject({ agentId: 'sub-1', question_id: 'q-sub' }); - - interactions.respond('q-sub', { answers: { q_0: 'opt_0_0' } }); - await bc.getCursor('s1'); - expect( - envelopes.find((e) => e.type === 'event.question.answered')?.payload, - ).toMatchObject({ agentId: 'sub-1', question_id: 'q-sub' }); - }); - - it('broadcasts approval requested / resolved as durable v1 events', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - interactions.enqueue({ - id: 'a1', - kind: 'approval', - payload: { - toolCallId: 'call_9', - toolName: 'Bash', - action: 'run', - display: { kind: 'command', command: 'ls' }, - }, - tags: { sessionId: 's1', turnId: 3 }, - }); - await bc.getCursor('s1'); - - expect(envelopes).toHaveLength(2); - expect(envelopes[0]).toMatchObject({ - type: 'event.session.work_changed', - seq: 1, - payload: { pending_interaction: 'approval' }, - }); - expect(envelopes[1]).toMatchObject({ - type: 'event.approval.requested', - seq: 2, - session_id: 's1', - payload: { - approval_id: 'a1', - session_id: 's1', - turn_id: 3, - tool_call_id: 'call_9', - tool_name: 'Bash', - action: 'run', - tool_input_display: { kind: 'command', command: 'ls' }, - }, - }); - expect(envelopes[1]!.volatile).toBeUndefined(); - - interactions.respond('a1', { decision: 'approved', scope: 'session' }); - await bc.getCursor('s1'); - - expect(envelopes).toHaveLength(4); - expect(envelopes[2]).toMatchObject({ - type: 'event.approval.resolved', - seq: 3, - session_id: 's1', - payload: { - approval_id: 'a1', - decision: 'approved', - scope: 'session', - }, - }); - expect((envelopes[2]!.payload as { resolved_at?: string }).resolved_at).toBeTypeOf('string'); - expect(envelopes[3]).toMatchObject({ - type: 'event.session.work_changed', - seq: 4, - payload: { pending_interaction: 'none' }, - }); - }); - - it('fans event.session.work_changed out to every connection, bypassing agent filters', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - sessions.set('s2', new FakeLifecycle()); - - const s1View = collectingTarget(); - const s2View = collectingTarget(); - await bc.subscribe('s1', s1View.target, new Set(['agent-0'])); - await bc.subscribe('s2', s2View.target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - await bc.getCursor('s1'); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - await bc.getCursor('s1'); - - for (const view of [s1View, s2View]) { - expect(view.envelopes.map((e) => e.type)).toEqual([ - 'event.session.work_changed', - 'event.session.work_changed', - ]); - expect(view.envelopes.every((e) => e.session_id === 's1')).toBe(true); - expect(view.envelopes.map((e) => e.payload)).toMatchObject([ - { busy: true, last_turn_reason: undefined }, - { busy: false, last_turn_reason: 'completed' }, - ]); - } - expect(s1View.envelopes.some((e) => e.type === 'turn.started')).toBe(false); - }); - - it('does not re-announce interactions already pending at activation, but still broadcasts their resolution', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - interactions.enqueue({ - id: 'q0', - kind: 'question', - payload: { questions: [{ question: 'Early', options: [{ label: 'A' }] }] }, - tags: { sessionId: 's1' }, - }); - - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - await bc.getCursor('s1'); - expect(envelopes).toHaveLength(0); - - interactions.respond('q0', { answers: { q_0: 'opt_0_0' } }); - await bc.getCursor('s1'); - expect(envelopes.map((e) => e.type)).toEqual([ - 'event.question.answered', - 'event.session.work_changed', - ]); - expect(envelopes[0]!.payload).toMatchObject({ question_id: 'q0' }); - expect(envelopes[1]!.payload).toMatchObject({ pending_interaction: 'none' }); - }); - - it('fans out the legacy background.task.* alias alongside native task.* for v1 clients', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - const info = { taskId: 't1', status: 'running', description: 'ls' }; - main.bus.emit(agentEvent('task.started', { info })); - main.bus.emit(agentEvent('task.terminated', { info: { ...info, status: 'completed' } })); - await bc.getCursor('s1'); - - expect(envelopes.map((e) => e.type)).toEqual([ - 'task.started', - 'background.task.started', - 'event.session.work_changed', - 'task.terminated', - 'background.task.terminated', - 'event.session.work_changed', - ]); - expect(envelopes[1]!.payload).toMatchObject({ - type: 'background.task.started', - info, - agentId: 'main', - sessionId: 's1', - }); - expect(envelopes[4]!.payload).toMatchObject({ - type: 'background.task.terminated', - agentId: 'main', - sessionId: 's1', - }); - expect(envelopes.every((e) => e.volatile === undefined)).toBe(true); - expect(envelopes.map((e) => e.seq)).toEqual([1, 2, 3, 4, 5, 6]); - }); - - it('delivers only the allowlisted agent events on live fan-out', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - const sub = lc.addAgent('agent-0'); - sessions.set('s1', lc); - - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target, new Set(['main'])); - - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - await bc.getCursor('s1'); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - await bc.getCursor('s1'); - sub.bus.emit(agentEvent('turn.ended', { turnId: 1 })); - await bc.getCursor('s1'); - - const agentEnvs = envelopes.filter((e) => e.type === 'turn.started' || e.type === 'turn.ended'); - expect(agentEnvs).toHaveLength(2); - expect( - agentEnvs.every((e) => (e.payload as { agentId: string }).agentId === 'main'), - ).toBe(true); - const workChanged = envelopes.filter((e) => e.type === 'event.session.work_changed'); - expect(workChanged).toHaveLength(2); - }); - - it('delivers every agent event when no filter is set', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - const sub = lc.addAgent('agent-0'); - sessions.set('s1', lc); - - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('turn.ended', { turnId: 1 })); - sub.bus.emit(agentEvent('turn.ended', { turnId: 1 })); - await bc.getCursor('s1'); - - const agentIds = envelopes - .filter((e) => e.type === 'turn.ended') - .map((e) => (e.payload as { agentId: string }).agentId); - expect(agentIds).toEqual(['main', 'agent-0']); - }); - - it('bypasses the agent filter for global events', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - - const { target, envelopes } = collectingTarget(); - await bc.subscribe('s1', target, new Set(['agent-0'])); - - eventBus.emit({ - type: 'session.meta.updated', - payload: { - agentId: 'main', - sessionId: 's1', - title: '测试', - patch: { title: '测试' }, - }, - }); - - await vi.waitFor(() => expect(envelopes).toHaveLength(1)); - expect(envelopes[0]!.type).toBe('session.meta.updated'); - }); - - it('replays only the allowlisted agent events while keeping the global sequence', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - const sub = lc.addAgent('agent-0'); - sessions.set('s1', lc); - - const dir2 = await mkdtemp(join(tmpdir(), 'kimi-broadcaster-test-')); - const bc2 = new SessionEventBroadcaster({ - eventsDir: dir2, - core: makeCore(sessions, eventBus), - maxBufferSize: 20, - }); - try { - const warm = collectingTarget(); - await bc2.subscribe('s1', warm.target); - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - await bc2.getCursor('s1'); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - await bc2.getCursor('s1'); - sub.bus.emit(agentEvent('turn.started', { turnId: 1 })); - await bc2.getCursor('s1'); - sub.bus.emit(agentEvent('turn.ended', { turnId: 1 })); - await bc2.getCursor('s1'); - main.bus.emit(agentEvent('turn.started', { turnId: 2 })); - await bc2.getCursor('s1'); - main.bus.emit(agentEvent('turn.ended', { turnId: 2, reason: 'completed' })); - await bc2.getCursor('s1'); - - const result = await bc2.getBufferedSince('s1', { seq: 0 }, new Set(['main'])); - expect(result.resyncRequired).toBe(false); - expect(result.events.map((e) => e.seq)).toEqual([1, 2, 3, 4, 6, 8, 9, 10, 11, 12]); - expect( - result.events.every((e) => (e.envelope.payload as { agentId: string }).agentId === 'main'), - ).toBe(true); - } finally { - await bc2.close(); - await rm(dir2, { recursive: true, force: true }); - } - }); - - it('fans each agent event out once when session activation calls race', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); - - await Promise.all([ - bc.subscribe('s1', target), - bc.getSnapshotState('s1'), - bc.getBufferedSince('s1', { seq: 0 }), - bc.getCursor('s1'), - bc.getSnapshotState('s1'), - ]); - await bc.subscribe('s1', target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'abc' })); - await bc.getCursor('s1'); - - expect( - envelopes - .filter((envelope) => envelope.type === 'assistant.delta') - .map((envelope) => ({ - offset: envelope.offset, - delta: (envelope.payload as { delta: string }).delta, - })), - ).toEqual([{ offset: 0, delta: 'abc' }]); - - eventBus.emit({ - type: 'event.workspace.deleted', - payload: { workspaceId: 'wd_late', root: '/repo/late' }, - }); - await bc.close(); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(await readdir(dir)).not.toContain('__global__.jsonl'); - }); - - describe('transcript streaming', () => { - function makeBroadcasterWithTranscript( - metaAgents?: Record, - ): SessionEventBroadcaster { - const core = makeCore(sessions, eventBus, metaAgents); - return new SessionEventBroadcaster({ - eventsDir: dir, - core, - maxBufferSize: 3, - transcriptService: new TranscriptService({ homeDir: dir, core }), - }); - } - - function transcriptEnvelopes(envelopes: readonly EventEnvelope[]): EventEnvelope[] { - return envelopes.filter( - (e) => e.type === 'transcript.reset' || e.type === 'transcript.ops', - ); - } - - interface OpsPayload { - agent_id: string; - ops: Array<{ op: string }>; - } - - it('sends transcript.reset on first subscription, then fans ops out filtered per grade', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const deltaView = collectingTarget(); - const blockView = collectingTarget(); - const turnView = collectingTarget(); - const plainView = collectingTarget(); - await bc.subscribe('s1', deltaView.target, undefined, { '*': 'delta' }); - await bc.subscribe('s1', blockView.target, undefined, { '*': 'block' }); - await bc.subscribe('s1', turnView.target, undefined, { '*': 'turn' }); - await bc.subscribe('s1', plainView.target); - - for (const view of [deltaView, blockView, turnView]) { - const resets = transcriptEnvelopes(view.envelopes); - expect(resets).toHaveLength(1); - expect(resets[0]).toMatchObject({ - type: 'transcript.reset', - volatile: true, - session_id: 's1', - payload: { agent_id: 'main', has_more_older: false, snapshot: { items: [] } }, - }); - expect(view.deliveries).toEqual(['subscription']); - } - expect(transcriptEnvelopes(plainView.envelopes)).toHaveLength(0); - - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - for (const view of [deltaView, blockView, turnView]) { - const batches = transcriptEnvelopes(view.envelopes).slice(-1); - for (const ops of batches) { - expect(ops.type).toBe('transcript.ops'); - expect(ops.volatile).toBe(true); - } - expect(batches.map((ops) => (ops.payload as OpsPayload).ops.map((o) => o.op))).toEqual([ - ['turn.upsert', 'meta.merge', 'meta.merge'], - ]); - } - expect(transcriptEnvelopes(plainView.envelopes)).toHaveLength(0); - - const turnBatchesBefore = transcriptEnvelopes(turnView.envelopes).length; - main.bus.emit(agentEvent('turn.step.started', { turnId: 1, step: 1 })); - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'Hi' })); - const deltaOps = transcriptEnvelopes(deltaView.envelopes).at(-1)!.payload as OpsPayload; - expect(deltaOps.ops.map((o) => o.op)).toEqual(['frame.upsert', 'append']); - const blockOps = transcriptEnvelopes(blockView.envelopes).at(-1)!.payload as OpsPayload; - expect(blockOps.ops.map((o) => o.op)).toEqual(['frame.upsert']); - expect(transcriptEnvelopes(turnView.envelopes)).toHaveLength(turnBatchesBefore); - - main.bus.emit(agentEvent('turn.step.completed', { turnId: 1, step: 1 })); - const flushed = transcriptEnvelopes(blockView.envelopes).at(-1)!.payload as OpsPayload; - expect(flushed.ops).toEqual([ - expect.objectContaining({ - op: 'frame.upsert', - frame: expect.objectContaining({ kind: 'text', text: 'Hi' }), - }), - expect.objectContaining({ op: 'step.upsert' }), - ]); - expect(transcriptEnvelopes(deltaView.envelopes).every((e) => e.volatile === true)).toBe(true); - }); - - it('re-sends transcript.reset on grade upgrade, not on equal or downgraded re-subscribe', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { '*': 'turn' }); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(1); - - await bc.subscribe('s1', view.target, undefined, { '*': 'turn' }); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(1); - - await bc.subscribe('s1', view.target, undefined, { '*': 'off' }); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(1); - - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(2); - expect(transcriptEnvelopes(view.envelopes).at(-1)!.type).toBe('transcript.reset'); - }); - - it('seeds transcript.reset for agents appearing after the subscription (roster-driven)', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - const offView = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - await bc.subscribe('s1', offView.target, undefined, { '*': 'off' }); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(1); - - const late = lc.addAgent('agent-0'); - const resets = transcriptEnvelopes(view.envelopes); - expect(resets).toHaveLength(2); - expect(resets.at(-1)).toMatchObject({ - type: 'transcript.reset', - payload: { agent_id: 'agent-0' }, - }); - expect(transcriptEnvelopes(offView.envelopes)).toHaveLength(0); - - late.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - const ops = transcriptEnvelopes(view.envelopes).at(-1)!; - expect(ops.type).toBe('transcript.ops'); - expect((ops.payload as OpsPayload).agent_id).toBe('agent-0'); - }); - - it('streams for a client subscribed before any agent exists', async () => { - const lc = new FakeLifecycle(); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - - const main = lc.addAgent('main'); - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - - const types = transcriptEnvelopes(view.envelopes).map((e) => e.type); - expect(types).toContain('transcript.reset'); - expect(types).toContain('transcript.ops'); - }); - - it('keeps delivering ops across a no-reset resubscribe', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { main: 'delta' }); - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - const before = transcriptEnvelopes(view.envelopes).length; - - const resub = bc.subscribe('s1', view.target, undefined, { main: 'delta' }); - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'x' })); - await resub; - - const after = transcriptEnvelopes(view.envelopes); - expect(after.length).toBeGreaterThan(before); - expect(after.some((e) => e.type === 'transcript.ops')).toBe(true); - }); - - it('forces a baseline reset when a cursor-based resubscribe flushes at the same grade', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { main: 'delta' }); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(1); - - await bc.subscribe('s1', view.target, undefined, { main: 'delta' }, { deferTranscriptReset: true }); - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'x' })); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(1); - - await bc.flushTranscriptSeed('s1', view.target); - const resets = transcriptEnvelopes(view.envelopes).filter((e) => e.type === 'transcript.reset'); - expect(resets).toHaveLength(2); - }); - - it('backfills wildcard-admitted roster agents before seeding their baseline', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript({ 'sub-1': { type: 'sub' } }); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - const ids = transcriptEnvelopes(view.envelopes) - .filter((e) => e.type === 'transcript.reset') - .map((e) => (e.payload as { agent_id: string }).agent_id) - .sort(); - expect(ids).toEqual(['main', 'sub-1']); - }); - - it('sends no resets when the target downgrades while the seed is in flight', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { '*': 'off' }); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(0); - - const pending = bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - await bc.subscribe('s1', view.target, undefined, { '*': 'off' }); - await pending; - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(0); - }); - - it('sends no resets when the target unsubscribes while the seed is in flight', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - const core = makeCore(sessions, eventBus, { 'sub-1': { type: 'sub' } }); - const service = new TranscriptService({ homeDir: dir, core }); - let releaseBackfill!: () => void; - const gate = new Promise((resolve) => { - releaseBackfill = resolve; - }); - const original = service.ensureAgentHistory.bind(service); - const backfillSpy = vi - .spyOn(service, 'ensureAgentHistory') - .mockImplementation(async (sessionId, agentId) => { - if (agentId === 'sub-1') await gate; - return original(sessionId, agentId); - }); - bc = new SessionEventBroadcaster({ - eventsDir: dir, - core, - maxBufferSize: 3, - transcriptService: service, - }); - - const view = collectingTarget(); - const pending = bc.subscribe('s1', view.target, undefined, { 'sub-1': 'delta' }); - await vi.waitFor(() => { - expect(backfillSpy).toHaveBeenCalledWith('s1', 'sub-1'); - }); - bc.unsubscribe('s1', view.target); - releaseBackfill(); - await pending; - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(0); - }); - - it('reattaches the ops fan-out when the session store is rebuilt after a drop', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - const core = makeCore(sessions, eventBus); - const service = new TranscriptService({ homeDir: dir, core }); - bc = new SessionEventBroadcaster({ - eventsDir: dir, - core, - maxBufferSize: 3, - transcriptService: service, - }); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - const opsBefore = transcriptEnvelopes(view.envelopes).filter( - (e) => e.type === 'transcript.ops', - ).length; - expect(opsBefore).toBeGreaterThan(0); - - service.dropSession('s1'); - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'x' })); - - const opsAfter = transcriptEnvelopes(view.envelopes).filter( - (e) => e.type === 'transcript.ops', - ).length; - expect(opsAfter).toBeGreaterThan(opsBefore); - }); - - it('delivers no transcript.ops before the baseline reset has landed', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const first = collectingTarget(); - await bc.subscribe('s1', first.target, undefined, { '*': 'delta' }); - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - - const second = collectingTarget(); - const pending = bc.subscribe('s1', second.target, undefined, { '*': 'delta' }); - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'x' })); - await pending; - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'y' })); - - const types = transcriptEnvelopes(second.envelopes).map((e) => e.type); - expect(types[0]).toBe('transcript.reset'); - expect(types.indexOf('transcript.ops')).toBeGreaterThan(types.indexOf('transcript.reset')); - }); - - it('seeds transcript resets for every graded agent regardless of the agent filter', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - lc.addAgent('sub-1'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, new Set(['main']), { '*': 'delta' }); - const resets = transcriptEnvelopes(view.envelopes).filter((e) => e.type === 'transcript.reset'); - expect(resets.map((e) => (e.payload as { agent_id: string }).agent_id)).toEqual([ - 'main', - 'sub-1', - ]); - - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - expect( - transcriptEnvelopes(view.envelopes).filter((e) => e.type === 'transcript.reset'), - ).toHaveLength(2); - }); - - it('delivers transcript frames past the agent filter', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, new Set(['main']), { '*': 'delta' }); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(1); - - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(2); - - const sub = lc.addAgent('sub-1'); - sub.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - const frames = transcriptEnvelopes(view.envelopes); - expect(frames).toHaveLength(4); - const subFrames = frames.filter( - (e) => (e.payload as { agent_id?: string }).agent_id === 'sub-1', - ); - expect(subFrames.map((e) => e.type)).toEqual(['transcript.reset', 'transcript.ops']); - }); - - it('sends an items-empty baseline reset marking older history, with global state and the watermark', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const full = collectingTarget(); - await bc.subscribe('s1', full.target, undefined, { main: 'delta' }); - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - main.bus.emit(agentEvent('turn.step.started', { turnId: 1, step: 1 })); - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'secret body' })); - main.bus.emit(agentEvent('turn.step.completed', { turnId: 1, step: 1 })); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - - const late = collectingTarget(); - await bc.subscribe('s1', late.target, undefined, { main: 'turn' }); - const resets = transcriptEnvelopes(late.envelopes).filter((e) => e.type === 'transcript.reset'); - expect(resets).toHaveLength(1); - const payload = resets[0]!.payload as { - snapshot: { - items: unknown[]; - tasks: unknown[]; - interactions: unknown[]; - attachments: unknown[]; - todos: unknown[]; - meta: unknown; - }; - has_more_older: boolean; - seq?: number; - }; - expect(payload.snapshot.items).toEqual([]); - expect(payload.has_more_older).toBe(true); - expect(payload.seq).toBeTypeOf('number'); - expect(payload.snapshot).toMatchObject({ - tasks: [], - interactions: [], - attachments: [], - todos: [], - }); - expect(JSON.stringify(payload.snapshot)).not.toContain('secret body'); - }); - - it('honours per-agent grade overrides over the wildcard', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { '*': 'off', main: 'delta' }); - - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(1); - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - expect(transcriptEnvelopes(view.envelopes).at(-1)!.type).toBe('transcript.ops'); - - const late = lc.addAgent('agent-0'); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(2); - late.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(2); - }); - - it('stamps ops payloads with the batch seq and resets with the watermark', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - const reset = transcriptEnvelopes(view.envelopes)[0]!; - expect(reset.type).toBe('transcript.reset'); - const watermark = (reset.payload as { seq?: number }).seq; - expect(watermark).toBeTypeOf('number'); - - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - - const ops = transcriptEnvelopes(view.envelopes).filter((e) => e.type === 'transcript.ops'); - expect(ops.length).toBeGreaterThan(0); - const seqs = ops.map((e) => (e.payload as { seq?: number }).seq); - expect(seqs.every((seq) => seq !== undefined && seq > watermark!)).toBe(true); - expect([...seqs].toSorted((a, b) => a! - b!)).toEqual(seqs); - expect(ops.every((e) => e.volatile === true && e.seq === reset.seq)).toBe(true); - }); - - it('replays journaled batches instead of a reset when transcript_since is covered', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const first = collectingTarget(); - await bc.subscribe('s1', first.target, undefined, { '*': 'delta' }); - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - const cursor = ( - transcriptEnvelopes(first.envelopes).at(-1)!.payload as { seq: number } - ).seq; - - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'hi' })); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - - const second = collectingTarget(); - await bc.subscribe('s1', second.target, undefined, { '*': 'delta' }, { - transcriptSince: { main: cursor }, - }); - const frames = transcriptEnvelopes(second.envelopes); - expect(frames.some((e) => e.type === 'transcript.reset')).toBe(false); - const replayed = frames.filter((e) => e.type === 'transcript.ops'); - expect(replayed.length).toBeGreaterThan(0); - const seqs = replayed.map((e) => (e.payload as { seq: number }).seq); - expect(seqs.every((seq) => seq > cursor)).toBe(true); - expect([...seqs].toSorted((a, b) => a - b)).toEqual(seqs); - - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'again' })); - expect(transcriptEnvelopes(second.envelopes).at(-1)!.type).toBe('transcript.ops'); - }); - - it('replays nothing (and no reset) when transcript_since is already current', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const first = collectingTarget(); - await bc.subscribe('s1', first.target, undefined, { '*': 'delta' }); - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - const cursor = ( - transcriptEnvelopes(first.envelopes).at(-1)!.payload as { seq: number } - ).seq; - - const second = collectingTarget(); - await bc.subscribe('s1', second.target, undefined, { '*': 'delta' }, { - transcriptSince: { main: cursor }, - }); - expect(transcriptEnvelopes(second.envelopes)).toHaveLength(0); - }); - - it('falls back to a watermarked reset when transcript_since is not covered', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const first = collectingTarget(); - await bc.subscribe('s1', first.target, undefined, { '*': 'delta' }); - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - - const second = collectingTarget(); - await bc.subscribe('s1', second.target, undefined, { '*': 'delta' }, { - transcriptSince: { main: 9999 }, - }); - const resets = transcriptEnvelopes(second.envelopes).filter( - (e) => e.type === 'transcript.reset', - ); - expect(resets).toHaveLength(1); - const watermark = (resets[0]!.payload as { seq?: number }).seq; - expect(watermark).toBeTypeOf('number'); - expect( - ( - transcriptEnvelopes(first.envelopes).filter((e) => e.type === 'transcript.ops').at(-1)! - .payload as { seq: number } - ).seq, - ).toBeLessThanOrEqual(watermark!); - }); - - it('suppresses transcript-projected session_events on graded connections only', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const graded = collectingTarget(); - const legacy = collectingTarget(); - await bc.subscribe('s1', graded.target, undefined, { '*': 'delta' }); - await bc.subscribe('s1', legacy.target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - main.bus.emit(agentEvent('turn.step.started', { turnId: 1, step: 1 })); - main.bus.emit(agentEvent('assistant.delta', { turnId: 1, delta: 'Hi' })); - main.bus.emit(agentEvent('tool.result', { turnId: 1, toolCallId: 'tc-1', output: 'ok' })); - await bc.getCursor('s1'); - - expect(transcriptEnvelopes(graded.envelopes).length).toBeGreaterThan(0); - const gradedTypes = graded.envelopes.map((e) => e.type); - expect(gradedTypes).not.toContain('turn.started'); - expect(gradedTypes).not.toContain('turn.step.started'); - expect(gradedTypes).not.toContain('assistant.delta'); - expect(gradedTypes).not.toContain('tool.result'); - expect(gradedTypes).not.toContain('agent.status.updated'); - - const legacyTypes = legacy.envelopes.map((e) => e.type); - expect(legacyTypes).toContain('turn.started'); - expect(legacyTypes).toContain('turn.step.started'); - expect(legacyTypes).toContain('assistant.delta'); - expect(legacyTypes).toContain('tool.result'); - expect(transcriptEnvelopes(legacy.envelopes)).toHaveLength(0); - }); - - it('keeps delivering lifecycle and global events to graded connections', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - - const late = lc.addAgent('agent-0'); - await bc.getCursor('s1'); - late.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - await bc.getCursor('s1'); - - const types = view.envelopes.map((e) => e.type); - expect(types).toContain('agent.created'); - expect(types).toContain('event.session.work_changed'); - expect(types).not.toContain('turn.started'); - }); - - it('suppresses per agent — agents outside the spec keep their session_events', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - const sub = lc.addAgent('agent-0'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { main: 'delta' }); - - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - sub.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - await bc.getCursor('s1'); - - const turns = view.envelopes.filter((e) => e.type === 'turn.started'); - expect(turns.map((e) => (e.payload as { agentId: string }).agentId)).toEqual(['agent-0']); - }); - - it('filters the replayed backlog by transcript grades', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - await bc.subscribe('s1', collectingTarget().target); - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - await bc.getCursor('s1'); - main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); - await bc.getCursor('s1'); - - const unfiltered = await bc.getBufferedSince('s1', { seq: 1 }); - expect(unfiltered.events.map((e) => e.envelope.type)).toEqual([ - 'event.session.work_changed', - 'turn.ended', - 'event.session.work_changed', - ]); - - const filtered = await bc.getBufferedSince('s1', { seq: 1 }, undefined, { '*': 'delta' }); - expect(filtered.events.map((e) => e.envelope.type)).toEqual([ - 'event.session.work_changed', - 'event.session.work_changed', - ]); - - const offSpec = await bc.getBufferedSince('s1', { seq: 1 }, undefined, { '*': 'off' }); - expect(offSpec.events.map((e) => e.envelope.type)).toEqual( - unfiltered.events.map((e) => e.envelope.type), - ); - }); - - it('unsubscribeTranscript detaches per agent: ops stop and legacy events resume for that agent only', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - const sub = lc.addAgent('agent-0'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - sub.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - await bc.getCursor('s1'); - expect(view.envelopes.map((e) => e.type)).not.toContain('turn.started'); - const opsBefore = transcriptEnvelopes(view.envelopes).filter((e) => e.type === 'transcript.ops'); - expect(new Set(opsBefore.map((e) => (e.payload as OpsPayload).agent_id))).toEqual( - new Set(['main', 'agent-0']), - ); - - bc.unsubscribeTranscript('s1', view.target, ['main']); - - main.bus.emit(agentEvent('turn.started', { turnId: 2, origin: { kind: 'user' } })); - sub.bus.emit(agentEvent('turn.started', { turnId: 2, origin: { kind: 'user' } })); - await bc.getCursor('s1'); - - const turns = view.envelopes.filter((e) => e.type === 'turn.started'); - expect(turns.map((e) => (e.payload as { agentId: string }).agentId)).toEqual(['main']); - const opsAfter = transcriptEnvelopes(view.envelopes) - .filter((e) => e.type === 'transcript.ops') - .slice(opsBefore.length); - expect(opsAfter.length).toBeGreaterThan(0); - expect(new Set(opsAfter.map((e) => (e.payload as OpsPayload).agent_id))).toEqual( - new Set(['agent-0']), - ); - }); - - it('unsubscribeTranscript without agent ids detaches the whole stream; a re-subscribe re-seeds', async () => { - const lc = new FakeLifecycle(); - const main = lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(1); - - bc.unsubscribeTranscript('s1', view.target); - - main.bus.emit(agentEvent('turn.started', { turnId: 1, origin: { kind: 'user' } })); - await bc.getCursor('s1'); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(1); - expect(view.envelopes.map((e) => e.type)).toContain('turn.started'); - - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }); - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(2); - expect(transcriptEnvelopes(view.envelopes).at(-1)!.type).toBe('transcript.reset'); - }); - - it('unsubscribeTranscript is idempotent and never activates a session', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - expect(() => bc.unsubscribeTranscript('nope', view.target)).not.toThrow(); - expect(() => bc.unsubscribeTranscript('s1', view.target)).not.toThrow(); - await bc.subscribe('s1', view.target); - expect(() => bc.unsubscribeTranscript('s1', view.target, ['main'])).not.toThrow(); - }); - - it('unsubscribeTranscript cancels a pending deferred baseline', async () => { - const lc = new FakeLifecycle(); - lc.addAgent('main'); - sessions.set('s1', lc); - bc = makeBroadcasterWithTranscript(); - - const view = collectingTarget(); - await bc.subscribe('s1', view.target, undefined, { '*': 'delta' }, { deferTranscriptReset: true }); - bc.unsubscribeTranscript('s1', view.target); - await bc.flushTranscriptSeed('s1', view.target); - - expect(transcriptEnvelopes(view.envelopes)).toHaveLength(0); - }); - }); -}); - -describe('sessionEventMessageSchema', () => { - const timestamp = '2026-08-27T00:00:00.000Z'; - - function envelope(payload: Record): Record { - return { - type: payload['type'], - seq: 3, - session_id: '__global__', - timestamp, - payload: { agentId: 'main', sessionId: '__global__', ...payload }, - }; - } - - it('accepts config changed, config warning, and model catalog changed envelopes', () => { - expect( - sessionEventMessageSchema.safeParse( - envelope({ - type: 'event.config.changed', - changedFields: ['defaultModel'], - config: { default_model: 'k2', providers: {} }, - }), - ).success, - ).toBe(true); - - expect( - sessionEventMessageSchema.safeParse( - envelope({ - type: 'event.config.warning', - warnings: [{ domain: 'loopControl', message: 'deprecated key' }, { message: 'other' }], - }), - ).success, - ).toBe(true); - - expect( - sessionEventMessageSchema.safeParse( - envelope({ - type: 'event.model_catalog.changed', - changed: [ - { provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 2, removed: 1 }, - ], - unchanged: ['openai-main'], - failed: [{ provider: 'managed:kimi-code', reason: 'network disabled' }], - }), - ).success, - ).toBe(true); - }); - - it('rejects malformed config and model catalog event envelopes', () => { - expect( - sessionEventMessageSchema.safeParse( - envelope({ type: 'event.config.changed', changedFields: 'defaultModel', config: {} }), - ).success, - ).toBe(false); - - expect( - sessionEventMessageSchema.safeParse( - envelope({ type: 'event.config.changed', changedFields: [], config: [] }), - ).success, - ).toBe(false); - - expect( - sessionEventMessageSchema.safeParse( - envelope({ type: 'event.config.warning', warnings: [{ domain: 'loopControl' }] }), - ).success, - ).toBe(false); - - expect( - sessionEventMessageSchema.safeParse( - envelope({ - type: 'event.model_catalog.changed', - changed: [], - failed: [], - }), - ).success, - ).toBe(false); - - expect( - sessionEventMessageSchema.safeParse( - envelope({ - type: 'event.model_catalog.changed', - changed: [], - unchanged: [], - failed: [{ provider: 'managed:kimi-code', reason: 42 }], - }), - ).success, - ).toBe(false); - }); -}); diff --git a/packages/kap-server/test/sessionEventJournal.test.ts b/packages/kap-server/test/sessionEventJournal.test.ts deleted file mode 100644 index e5b2608940c..00000000000 --- a/packages/kap-server/test/sessionEventJournal.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - type EventEnvelope, - SessionEventJournal, -} from '../src/transport/ws/v1/sessionEventJournal'; - -function envelope(seq: number): EventEnvelope { - return { - type: 'turn.started', - seq, - timestamp: new Date().toISOString(), - payload: { seq }, - }; -} - -describe('SessionEventJournal', () => { - let dir: string; - let filePath: string; - - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'kimi-journal-test-')); - filePath = join(dir, 'sess_1.jsonl'); - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - it('assigns monotonic seq and reads back in order', async () => { - const j = await SessionEventJournal.open(filePath); - expect(j.epoch).toMatch(/^ep_/); - expect(j.seq).toBe(0); - - j.append(j.nextSeq(), envelope(1)); - j.append(j.nextSeq(), envelope(2)); - j.append(j.nextSeq(), envelope(3)); - expect(j.seq).toBe(3); - - const all = await j.readSince(0, 100); - expect(all.map((e) => e.seq)).toEqual([1, 2, 3]); - await j.close(); - }); - - it('recovers seq and epoch across reopen', async () => { - const j1 = await SessionEventJournal.open(filePath); - const epoch = j1.epoch; - j1.append(j1.nextSeq(), envelope(1)); - j1.append(j1.nextSeq(), envelope(2)); - await j1.close(); - - j1.append(j1.nextSeq(), envelope(3)); - await j1.flush(); - - const j2 = await SessionEventJournal.open(filePath); - expect(j2.epoch).toBe(epoch); - expect(j2.seq).toBe(2); - expect(j2.nextSeq()).toBe(3); - await j2.close(); - }); - - it('rotates to a fresh epoch when the header is corrupt', async () => { - const j1 = await SessionEventJournal.open(filePath); - const epoch = j1.epoch; - j1.append(j1.nextSeq(), envelope(1)); - await j1.close(); - - await writeFile(filePath, 'this is not json\n', 'utf8'); - - const j2 = await SessionEventJournal.open(filePath); - expect(j2.epoch).toMatch(/^ep_/); - expect(j2.epoch).not.toBe(epoch); - expect(j2.seq).toBe(0); - await j2.close(); - }); - - it('readSince honors the exclusive lower bound and the limit', async () => { - const j = await SessionEventJournal.open(filePath); - for (let i = 1; i <= 5; i++) j.append(j.nextSeq(), envelope(i)); - - const page = await j.readSince(2, 2); - expect(page.map((e) => e.seq)).toEqual([3, 4]); - await j.close(); - }); - - it('readSince on a missing file returns empty', async () => { - const j = await SessionEventJournal.open(filePath); - const out = await j.readSince(0, 100); - expect(out).toEqual([]); - await j.close(); - }); - - it('flushes appends that arrive while a flush is in flight', async () => { - const j = await SessionEventJournal.open(filePath); - for (let i = 1; i <= 12; i++) j.append(j.nextSeq(), envelope(i)); - const deadline = Date.now() + 2000; - let lines = 0; - while (Date.now() < deadline) { - try { - lines = (await readFile(filePath, 'utf8')).trim().split('\n').length; - } catch { - lines = 0; - } - if (lines >= 13) break; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - expect(lines).toBe(13); - await j.close(); - }); -}); diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 3e2dbc0d932..540c5b0f18f 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -32,7 +32,6 @@ import { sessionDirOf, type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; -import { SessionMetaUpdated } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetaEvents'; import { TurnStarted } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; import { sessionWarningsResponseSchema } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; @@ -66,7 +65,6 @@ interface SessionWire { usage: { input_tokens: number }; permission_rules: unknown[]; message_count: number; - last_seq: number; } interface PageWire { @@ -369,7 +367,6 @@ describe('server-v2 /api/v1/sessions', () => { expect(body.data.agent_config).toEqual({ model: '' }); expect(body.data.permission_rules).toEqual([]); expect(body.data.message_count).toBe(0); - expect(body.data.last_seq).toBe(0); expect(Number.isNaN(Date.parse(body.data.created_at))).toBe(false); }); @@ -489,23 +486,6 @@ describe('server-v2 /api/v1/sessions', () => { expect(got.body.data.agent_config).toEqual({ model: 'stub' }); }); - it('reports the journaled event watermark as last_seq', async () => { - const cwd = home as string; - const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); - const id = created.body.data.id; - - const initial = await getJson(`/api/v1/sessions/${id}`); - const baseline = initial.body.data.last_seq; - - const renamed = await postJson(`/api/v1/sessions/${id}/profile`, { - title: 'watermark probe', - }); - expect(renamed.body.code).toBe(0); - - const got = await getJson(`/api/v1/sessions/${id}`); - expect(got.body.data.last_seq).toBeGreaterThan(baseline); - }); - it('supports exclude_empty when listing sessions', async () => { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); @@ -1014,38 +994,10 @@ describe('server-v2 /api/v1/sessions', () => { } }); - it('keeps failed journal cleanup retriable without publishing deletion', async () => { - const created = await postJson('/api/v1/sessions', { metadata: { cwd: home } }); - const id = created.body.data.id; - const journalPath = join(home!, 'server', 'events', `${id}.jsonl`); - await vi.waitFor(async () => expect(await readFile(journalPath, 'utf8')).toContain('journal_header')); - await closeSessionById(server!.core.accessor, id); - await rm(journalPath); - await mkdir(journalPath); - const events: Event2[] = []; - const sub = server!.core.accessor.get(IEventService).subscribe((event) => events.push(event)); - try { - const failed = await postJson(`/api/v1/sessions/${id}:delete`); - expect(failed.body.code).not.toBe(0); - expect(await server!.core.accessor.get(ISessionManager).status(id)).toBeDefined(); - expect(events.filter((event) => event.type === 'event.session.deleted')).toEqual([]); - await rm(journalPath, { recursive: true }); - const retried = await postJson<{ deleted: boolean }>(`/api/v1/sessions/${id}:delete`); - expect(retried.body.data).toEqual({ deleted: true }); - expect(events.filter((event) => event.type === 'event.session.deleted')).toHaveLength(1); - await expect(readFile(journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); - } finally { - sub.dispose(); - } - }); - it.each(['closing', 'cleanup'] as const)('waits for %s before recreating an explicit session id', async (phase) => { const manager = server!.core.accessor.get(ISessionManager); const created = await postJson('/api/v1/sessions', { metadata: { cwd: home } }); const id = created.body.data.id; - const journalPath = join(home!, 'server', 'events', `${id}.jsonl`); - await vi.waitFor(async () => expect(await readFile(journalPath, 'utf8')).toContain('journal_header')); - const oldJournal = await readFile(journalPath, 'utf8'); let enter!: () => void; let release!: () => void; const entered = new Promise((resolve) => { enter = resolve; }); @@ -1069,14 +1021,6 @@ describe('server-v2 /api/v1/sessions', () => { release(); await deletion; await creation; - server!.core.accessor.get(IEventService).publish(new SessionMetaUpdated({ - payload: { sessionId: id, agentId: 'main', patch: { title: 'Recreated session' } }, - })); - await vi.waitFor(async () => { - const journal = await readFile(journalPath, 'utf8'); - expect(journal).toContain('journal_header'); - expect(JSON.parse(journal.split('\n')[0]!).epoch).not.toBe(JSON.parse(oldJournal.split('\n')[0]!).epoch); - }); expect(manager.get(id)).toBeDefined(); } finally { release(); @@ -1439,12 +1383,12 @@ describe('server-v2 /api/v1/sessions', () => { const listed = await getJson(`/api/v1/sessions/${forkedId}`); expect(listed.body.code).toBe(0); - const transcript = await getJson<{ - items: { kind: string; marker?: string; payload?: { path?: string } }[]; - }>(`/api/v1/sessions/${forkedId}/transcript?agent_id=main`); - expect(transcript.body.code).toBe(0); - const revisionMarker = transcript.body.data.items.find( - (item) => item.kind === 'marker' && item.marker === 'plan.revision', + const history = await getJson<{ + messages: { type: string; subtype?: string; payload?: { path?: string } }[]; + }>(`/api/v1/sessions/${forkedId}/history`); + expect(history.body.code).toBe(0); + const revisionMarker = history.body.data.messages.find( + (message) => message.type === 'system' && message.subtype === 'plan.revision', ); expect(revisionMarker?.payload?.path).toContain(forkedId); diff --git a/packages/kap-server/test/skills.test.ts b/packages/kap-server/test/skills.test.ts index f4b8c1db707..51219ddac94 100644 --- a/packages/kap-server/test/skills.test.ts +++ b/packages/kap-server/test/skills.test.ts @@ -265,18 +265,21 @@ describe('server-v2 /api/v1 skills', () => { expect(body.code).toBe(0); expect(body.data).toEqual({ activated: true, skill_name: 'update-config' }); - const messages = await getJson<{ - items: Array<{ role: string; content: Array<{ type: string; text?: string }> }>; - }>(`/api/v1/sessions/${id}/messages`); - const userMsg = messages.body.data.items.find( + const history = await getJson<{ + messages: Array<{ + type: string; + text?: Array<{ type: string; text?: string }>; + }>; + }>(`/api/v1/sessions/${id}/history`); + const userMsg = history.body.data.messages.find( (m) => - m.role === 'user' && - m.content.some((part) => part.text?.includes('User activated the skill')), + m.type === 'user' && + (m.text ?? []).some((part) => part.text?.includes('User activated the skill')), ); expect(userMsg).toBeDefined(); - expect(userMsg!.content[0]?.type).toBe('text'); - expect(userMsg!.content[0]?.text).toContain('User activated the skill "update-config"'); - const notice = userMsg!.content[1]; + expect(userMsg!.text?.[0]?.type).toBe('text'); + expect(userMsg!.text?.[0]?.text).toContain('User activated the skill "update-config"'); + const notice = userMsg!.text?.[1]; expect(notice?.type).toBe('text'); expect(notice?.text).toContain('Attached file "note.txt"'); expect(notice?.text).toContain(`${noteBytes.length} bytes`); @@ -315,41 +318,29 @@ describe('server-v2 /api/v1 skills', () => { expect(body.code).toBe(0); expect(body.data).toEqual({ activated: true, skill_name: 'update-config' }); - const messages = await getJson<{ - items: Array<{ role: string; content: Array<{ type: string; text?: string }> }>; - }>(`/api/v1/sessions/${id}/messages`); - const userMsg = messages.body.data.items.find( + const history = await getJson<{ + messages: Array<{ + type: string; + attachment_ids?: string[]; + text?: Array<{ type: string; text?: string }>; + }>; + }>(`/api/v1/sessions/${id}/history`); + const userMsg = history.body.data.messages.find( (m) => - m.role === 'user' && - m.content.some((part) => part.text?.includes('User activated the skill')), + m.type === 'user' && + (m.text ?? []).some((part) => part.text?.includes('User activated the skill')), ); expect(userMsg).toBeDefined(); - const notice = userMsg!.content[1]; + const notice = userMsg!.text?.[1]; expect(notice).toEqual({ type: 'text', text: `Attached file "note.txt" (application/octet-stream, ${noteBytes.length} bytes): ${sourcePath} — open it with the Read tool`, + meta: {}, }); - const transcript = await getJson<{ - items: Array<{ kind: string; attachmentIds?: string[] }>; - attachments: Array<{ - attachmentId: string; - mediaType: string; - name?: string; - size?: number; - source?: unknown; - }>; - }>(`/api/v1/sessions/${id}/transcript?agent_id=main`); - const transcriptAttachments = transcript.body.data.attachments; - expect(transcriptAttachments).toHaveLength(1); - expect(transcriptAttachments[0]).toMatchObject({ - mediaType: 'application/octet-stream', - name: 'note.txt', - size: noteBytes.length, - }); - expect(transcriptAttachments[0]).not.toHaveProperty('source'); - const turn = transcript.body.data.items.find((item) => item.kind === 'turn'); - expect(turn?.attachmentIds).toEqual([transcriptAttachments[0]!.attachmentId]); + const turn = history.body.data.messages.find((m) => m.type === 'turn'); + expect(turn?.attachment_ids).toHaveLength(1); + expect(userMsg!.attachment_ids).toEqual(turn?.attachment_ids); }); it('rejects a relative attachment path on skill activation (40001)', async () => { diff --git a/packages/kap-server/test/snapshot.test.ts b/packages/kap-server/test/snapshot.test.ts deleted file mode 100644 index bee4ffb9105..00000000000 --- a/packages/kap-server/test/snapshot.test.ts +++ /dev/null @@ -1,578 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { - type Event2, - IAgentBlobService, - IAgentContextMemoryService, - IAgentScopeContext, - IAppendLogStore, - IEventBus, - IAgentLifecycleService, - IAgentProfileService, - IAgentPromptService, - ISessionContext, - ISessionIndex, - ISessionMetadata, - ISessionLifecycleService, - ISessionTokenCountingService, - ISessionUsageService, - IWireService, - ISessionManager, - ITelemetryService, - IWorkspaceService, - agentContextOf, - getLiveSessionById, - resumeSessionById, -} from '@moonshot-ai/agent-core-v2'; -import { sessionSnapshotResponseSchema } from '../src/protocol/rest-snapshot'; -import { emptySessionUsage } from '../src/protocol/session'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -import { registerSnapshotRoutes } from '../src/routes/snapshot'; -import { type RunningServer, startServer } from '../src/start'; -import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; -import { authHeaders } from './helpers/auth'; - -function fakeAccessor(entries: ReadonlyArray) { - const services = new Map(entries); - return { - get(id: unknown): T { - if (!services.has(id)) { - throw new Error(`unexpected service request: ${String(id)}`); - } - return services.get(id) as T; - }, - }; -} - -describe('server-v2 snapshot route enrichment', () => { - it('attaches current_prompt_id to an in-flight turn from prompt active state', async () => { - const sessionId = 'sess_snapshot'; - const promptId = 'msg_snapshot_prompt'; - const workspaceId = 'wd_snapshot_012345abcdef'; - const now = Date.parse('2026-01-01T00:00:00.000Z'); - const main = { - accessor: fakeAccessor([ - [IAgentContextMemoryService, { get: () => [] }], - [ - IAgentPromptService, - { list: () => ({ active: { id: promptId }, pending: [] }) }, - ], - [IWireService, { flush: async () => {} }], - [IAgentScopeContext, { scope: () => 'scope/sess_snapshot' }], - [IAgentBlobService, { loadParts: async (parts: unknown) => parts }], - [ - IAgentProfileService, - { - getModelCapabilities: () => ({ max_input_tokens: 262144 }), - getModel: () => 'kimi-for-test', - }, - ], - [ - ISessionUsageService, - { - status: () => ({ - total: { inputOther: 120, output: 34, inputCacheRead: 56, inputCacheCreation: 7 }, - }), - }, - ], - [ISessionTokenCountingService, { statusSize: () => 4321 }], - ]), - }; - const session = { - accessor: fakeAccessor([ - [ISessionContext, { workspaceId }], - [ - ISessionMetadata, - { - read: async () => ({ - id: sessionId, - title: 'Snapshot', - createdAt: now, - updatedAt: now, - archived: false, - }), - }, - ], - [ - IAgentLifecycleService, - { - create: async () => ({ agentId: 'main', generation: 1 }) as never, - handleOf: () => main, - list: () => [], - }, - ], - ]), - }; - const handler = { - accessor: fakeAccessor([ - [ - ISessionLifecycleService, - { resume: async () => session, get: () => undefined }, - ], - ]), - }; - const core = { - accessor: fakeAccessor([ - [ - ISessionIndex, - { - get: async () => ({ - id: sessionId, - workspaceId, - cwd: '/workspace', - createdAt: now, - updatedAt: now, - archived: false, - }), - }, - ], - [ - ISessionManager, - { - resume: async () => session, - get: () => undefined, - list: () => [], - }, - ], - [IWorkspaceService, { get: async () => ({ root: '/workspace' }) }], - [ITelemetryService, { withContext: () => ({ track2: () => {} }) }], - [ - IAppendLogStore, - { - read: async function* () {}, - }, - ], - ]), - }; - const broadcaster = { - getSnapshotState: async () => ({ - seq: 1, - epoch: 'ep_snapshot', - inFlightTurn: { - turn_id: 7, - assistant_text: 'Hello', - thinking_text: '', - running_tools: [], - }, - subagents: [ - { - id: 'agent-1', - session_id: sessionId, - kind: 'subagent', - description: 'task agent-1', - status: 'running', - subagent_phase: 'working', - parent_tool_call_id: 'tc_swarm_1', - swarm_index: 0, - run_in_background: false, - created_at: new Date(now).toISOString(), - }, - ], - }), - }; - - let routeHandler: - | (( - req: { id: string; params: { session_id: string } }, - reply: { send(payload: unknown): unknown }, - ) => Promise | void) - | undefined; - registerSnapshotRoutes( - { - get: (_path, _options, handler) => { - routeHandler = handler; - }, - }, - { - core: core as never, - broadcaster: broadcaster as never, - }, - ); - - let payload: unknown; - await routeHandler?.( - { id: 'req_snapshot', params: { session_id: sessionId } }, - { - send: (value) => { - payload = value; - }, - }, - ); - - const body = payload as { code: number; data: unknown }; - expect(body.code).toBe(0); - const snap = sessionSnapshotResponseSchema.parse(body.data); - expect(snap.in_flight_turn).toMatchObject({ - turn_id: 7, - assistant_text: 'Hello', - current_prompt_id: promptId, - }); - expect(snap.session.usage).toEqual({ - input_tokens: 120, - output_tokens: 34, - cache_read_tokens: 56, - cache_creation_tokens: 7, - context_tokens: 4321, - context_limit: 262144, - }); - expect(snap.session.agent_config.model).toBe('kimi-for-test'); - expect(snap.subagents).toEqual([ - expect.objectContaining({ - id: 'agent-1', - kind: 'subagent', - subagent_phase: 'working', - parent_tool_call_id: 'tc_swarm_1', - swarm_index: 0, - run_in_background: false, - }), - ]); - }); - - it('keeps the placeholder usage when the main agent exposes no status services', async () => { - const sessionId = 'sess_snapshot_degraded'; - const workspaceId = 'wd_snapshot_abcdef012345'; - const now = Date.parse('2026-01-01T00:00:00.000Z'); - const main = { - accessor: fakeAccessor([ - [IAgentContextMemoryService, { get: () => [] }], - [IWireService, { flush: async () => {} }], - [IAgentScopeContext, { scope: () => 'scope/sess_snapshot_degraded' }], - [IAgentBlobService, { loadParts: async (parts: unknown) => parts }], - [IAgentProfileService, undefined], - [ISessionUsageService, undefined], - [ISessionTokenCountingService, undefined], - ]), - }; - const session = { - accessor: fakeAccessor([ - [ISessionContext, { workspaceId }], - [ - ISessionMetadata, - { - read: async () => ({ - id: sessionId, - title: 'Snapshot degraded', - createdAt: now, - updatedAt: now, - archived: false, - }), - }, - ], - [ - IAgentLifecycleService, - { - create: async () => ({ agentId: 'main', generation: 1 }) as never, - handleOf: () => main, - list: () => [], - }, - ], - ]), - }; - const handler = { - accessor: fakeAccessor([ - [ - ISessionLifecycleService, - { resume: async () => session, get: () => undefined }, - ], - ]), - }; - const core = { - accessor: fakeAccessor([ - [ - ISessionIndex, - { - get: async () => ({ - id: sessionId, - workspaceId, - cwd: '/workspace', - createdAt: now, - updatedAt: now, - archived: false, - }), - }, - ], - [ - ISessionManager, - { - resume: async () => session, - get: () => undefined, - list: () => [], - }, - ], - [IWorkspaceService, { get: async () => ({ root: '/workspace' }) }], - [ITelemetryService, { withContext: () => ({ track2: () => {} }) }], - [ - IAppendLogStore, - { - read: async function* () {}, - }, - ], - ]), - }; - const broadcaster = { - getSnapshotState: async () => ({ - seq: 1, - epoch: 'ep_snapshot', - inFlightTurn: null, - subagents: [], - }), - }; - - let routeHandler: - | (( - req: { id: string; params: { session_id: string } }, - reply: { send(payload: unknown): unknown }, - ) => Promise | void) - | undefined; - registerSnapshotRoutes( - { - get: (_path, _options, handler) => { - routeHandler = handler; - }, - }, - { - core: core as never, - broadcaster: broadcaster as never, - }, - ); - - let payload: unknown; - await routeHandler?.( - { id: 'req_snapshot_degraded', params: { session_id: sessionId } }, - { - send: (value) => { - payload = value; - }, - }, - ); - - const body = payload as { code: number; data: unknown }; - expect(body.code).toBe(0); - const snap = sessionSnapshotResponseSchema.parse(body.data); - expect(snap.session.usage).toEqual(emptySessionUsage()); - expect(snap.session.agent_config.model).toBe(''); - }); -}); - -describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => { - let server: RunningServer | undefined; - let home: string | undefined; - let base: string; - - beforeAll(async () => { - home = await mkdtemp(join(tmpdir(), 'kimi-snapshot-test-')); - server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - base = `http://127.0.0.1:${server.port}`; - }); - - afterAll(async () => { - if (server !== undefined) { - await server.close(); - server = undefined; - } - if (home !== undefined) { - await rm(home, { recursive: true, force: true }); - home = undefined; - } - }); - - async function createSession(): Promise { - const res = await fetch(`${base}/api/v1/sessions`, { - method: 'POST', - headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), - body: JSON.stringify({ metadata: { cwd: home } }), - } as never); - const body = (await res.json()) as { code: number; data: { id: string } }; - expect(body.code).toBe(0); - return body.data.id; - } - - async function ensureMainAgent(sessionId: string): Promise { - const session = getLiveSessionById(server!.core.accessor, sessionId); - const agents = session!.accessor.get(IAgentLifecycleService); - if (agents.handleOf('main') === undefined) await agents.create({ agentId: 'main' }); - } - - function emit(sessionId: string, event: Event2): void { - const session = getLiveSessionById(server!.core.accessor, sessionId); - const main = session!.accessor.get(IAgentLifecycleService).handleOf('main'); - main!.accessor.get(IEventBus).publish(event); - } - - async function snapshot(sid: string) { - const res = await fetch(`${base}/api/v1/sessions/${sid}/snapshot`, { - headers: authHeaders(server as RunningServer), - } as never); - const body = (await res.json()) as { code: number; data: unknown }; - expect(body.code).toBe(0); - return sessionSnapshotResponseSchema.parse(body.data); - } - - it('returns a well-formed snapshot for a fresh session', async () => { - const sid = await createSession(); - const snap = await snapshot(sid); - - expect(snap.session.id).toBe(sid); - expect(snap.as_of_seq).toBe(1); - expect(snap.epoch).toMatch(/^ep_/); - expect(snap.messages.items).toEqual([]); - expect(snap.in_flight_turn).toBeNull(); - expect(snap.pending_approvals).toEqual([]); - expect(snap.pending_questions).toEqual([]); - }); - - it('reflects the durable watermark and in-flight turn after events', async () => { - const sid = await createSession(); - await ensureMainAgent(sid); - await snapshot(sid); - - emit(sid, { - type: 'turn.started', - turnId: 1, - } as unknown as Event2); - emit(sid, { type: 'assistant.delta', turnId: 1, delta: 'Hello' } as unknown as Event2); - - const snap = await snapshot(sid); - expect(snap.as_of_seq).toBeGreaterThanOrEqual(2); - expect(snap.in_flight_turn).toMatchObject({ - turn_id: 1, - assistant_text: 'Hello', - }); - }); - - it('serves the real usage ledger instead of the zero placeholder', async () => { - const sid = await createSession(); - await ensureMainAgent(sid); - const session = getLiveSessionById(server!.core.accessor, sid); - const main = session!.accessor.get(IAgentLifecycleService).handleOf('main')!; - await main.accessor.get(ISessionUsageService).record(agentContextOf(main), 'kimi-for-test', { - inputOther: 120, - output: 34, - inputCacheRead: 56, - inputCacheCreation: 7, - }); - main.accessor.get(IAgentContextMemoryService).append({ - role: 'user', - content: [{ type: 'text', text: 'hello' }], - toolCalls: [], - }); - - const snap = await snapshot(sid); - expect(snap.session.usage.input_tokens).toBe(120); - expect(snap.session.usage.output_tokens).toBe(34); - expect(snap.session.usage.cache_read_tokens).toBe(56); - expect(snap.session.usage.cache_creation_tokens).toBe(7); - expect(snap.session.usage.context_tokens).toBeGreaterThan(0); - expect(snap.session.usage.context_limit).toBeUndefined(); - }); - - it('returns 404 for an unknown session', async () => { - const res = await fetch(`${base}/api/v1/sessions/sess_does_not_exist/snapshot`, { - headers: authHeaders(server as RunningServer), - } as never); - const body = (await res.json()) as { code: number }; - expect(body.code).not.toBe(0); - }); - - it('loads a cold (not live) session instead of 404', async () => { - const sid = await createSession(); - - await server!.close(); - server = undefined; - server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - base = `http://127.0.0.1:${server.port}`; - - expect(getLiveSessionById(server!.core.accessor, sid)).toBeUndefined(); - - const snap = await snapshot(sid); - expect(snap.session.id).toBe(sid); - }); - - it('returns the persisted transcript for a cold session', async () => { - const sid = await createSession(); - const live = getLiveSessionById(server!.core.accessor, sid); - if (live === undefined) throw new Error(`session ${sid} not found`); - const metaScope = live.accessor.get(ISessionContext).metaScope; - - const wireDir = join(home as string, metaScope, 'agents', 'main'); - await mkdir(wireDir, { recursive: true }); - const records = [ - { type: 'metadata', protocol_version: '1.4', created_at: Date.now() }, - { - type: 'context.append_message', - message: { role: 'user', content: [{ type: 'text', text: 'hello-from-disk' }], toolCalls: [] }, - }, - { - type: 'context.append_message', - message: { - role: 'assistant', - content: [{ type: 'text', text: 'hi-from-disk' }], - toolCalls: [], - }, - }, - ]; - await writeFile( - join(wireDir, 'wire.jsonl'), - records.map((r) => JSON.stringify(r)).join('\n') + '\n', - 'utf-8', - ); - - await server!.close(); - server = undefined; - server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - base = `http://127.0.0.1:${server.port}`; - - expect(getLiveSessionById(server!.core.accessor, sid)).toBeUndefined(); - - const snap = await snapshot(sid); - expect(snap.session.id).toBe(sid); - expect(snap.messages.items).toHaveLength(2); - expect((snap.messages.items[0]!.content[0] as { text: string }).text).toBe('hello-from-disk'); - expect((snap.messages.items[1]!.content[0] as { text: string }).text).toBe('hi-from-disk'); - expect(snap.epoch).toMatch(/^ep_/); - }); - - it('serves a v1-layout session (ISO timestamps, no id field) without crashing', async () => { - const sid = await createSession(); - const session = getLiveSessionById(server!.core.accessor, sid); - if (session === undefined) throw new Error(`session ${sid} not found`); - const metaScope = session.accessor.get(ISessionContext).metaScope; - - await server!.close(); - server = undefined; - const statePath = join(home as string, metaScope, 'state.json'); - await writeFile( - statePath, - JSON.stringify({ - title: 'v1 session', - createdAt: '2026-06-01T10:00:00.000Z', - updatedAt: '2026-06-01T11:00:00.000Z', - archived: false, - custom: { source: 'v1' }, - }), - ); - - server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - base = `http://127.0.0.1:${server.port}`; - - const resumed = await resumeSessionById(server!.core.accessor, sid); - if (resumed === undefined) throw new Error(`session ${sid} failed to resume`); - await resumed.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); - const main = resumed.accessor.get(IAgentLifecycleService).handleOf('main')!; - const context = main.accessor.get(IAgentContextMemoryService); - context.append({ role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }); - context.append({ role: 'assistant', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }); - - const snap = await snapshot(sid); - expect(snap.session.id).toBe(sid); - expect(snap.session.title).toBe('v1 session'); - expect(Number.isNaN(Date.parse(snap.session.created_at))).toBe(false); - expect(snap.messages.items.length).toBeGreaterThan(0); - for (const message of snap.messages.items) { - expect(Number.isNaN(Date.parse(message.created_at))).toBe(false); - } - }); -}); diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts deleted file mode 100644 index a466f7b3483..00000000000 --- a/packages/kap-server/test/subagentRosterTracker.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { Event } from '../src/transport/ws/v1/events'; -import { describe, expect, it } from 'vitest'; - -import { SubagentRosterTracker } from '../src/transport/ws/v1/subagentRosterTracker'; - -const SID = 'sess_1'; - -function ev(partial: Record): Event { - return { agentId: 'main', sessionId: SID, ...partial } as unknown as Event; -} - -function spawn(subagentId: string, extra: Record = {}): Event { - return ev({ - type: 'subagent.spawned', - subagentId, - subagentName: 'kimi-subagent', - parentToolCallId: 'tc_swarm_1', - description: `task ${subagentId}`, - swarmIndex: 0, - runInBackground: false, - ...extra, - }); -} - -describe('SubagentRosterTracker', () => { - it('seeds a roster entry from subagent.spawned with the swarm identity metadata', () => { - const t = new SubagentRosterTracker(); - t.apply(SID, spawn('agent-1', { swarmIndex: 2, model: 'provider/secondary', thinkingEffort: 'low' })); - - expect(t.get(SID)).toEqual([ - expect.objectContaining({ - id: 'agent-1', - session_id: SID, - kind: 'subagent', - description: 'task agent-1', - status: 'running', - subagent_phase: 'queued', - subagent_type: 'kimi-subagent', - parent_tool_call_id: 'tc_swarm_1', - swarm_index: 2, - run_in_background: false, - model: 'provider/secondary', - thinking_effort: 'low', - }), - ]); - }); - - it('treats an empty parentToolCallId as absent', () => { - const t = new SubagentRosterTracker(); - t.apply(SID, spawn('agent-1', { parentToolCallId: '' })); - expect(t.get(SID)[0]?.parent_tool_call_id).toBeUndefined(); - }); - - it('skips background subagents — REST /tasks already serves them after a refresh', () => { - const t = new SubagentRosterTracker(); - t.apply(SID, spawn('agent-1', { runInBackground: true })); - expect(t.get(SID)).toEqual([]); - }); - - it('drops the entry when a foreground subagent detaches into a background task', () => { - const t = new SubagentRosterTracker(); - t.apply(SID, spawn('agent-1')); - - const taskStarted = (detached: boolean): Event => - ev({ - type: 'task.started', - info: { - taskId: 'task_1', - kind: 'agent', - agentId: 'agent-1', - detached, - description: 'task agent-1', - status: 'running', - startedAt: 1, - endedAt: null, - }, - }); - - t.apply(SID, taskStarted(false)); - expect(t.get(SID)).toHaveLength(1); - - t.apply(SID, taskStarted(true)); - expect(t.get(SID)).toEqual([]); - }); - - it('follows the subagent phase transitions', () => { - const t = new SubagentRosterTracker(); - t.apply(SID, spawn('agent-1')); - t.apply(SID, ev({ type: 'subagent.started', subagentId: 'agent-1' })); - expect(t.get(SID)[0]).toMatchObject({ subagent_phase: 'working' }); - expect(t.get(SID)[0]?.started_at).toBeDefined(); - - t.apply( - SID, - ev({ type: 'subagent.suspended', subagentId: 'agent-1', reason: 'rate limit' }), - ); - expect(t.get(SID)[0]).toMatchObject({ - subagent_phase: 'suspended', - suspended_reason: 'rate limit', - }); - - const startedAt = t.get(SID)[0]?.started_at; - t.apply(SID, ev({ type: 'subagent.started', subagentId: 'agent-1' })); - expect(t.get(SID)[0]).toMatchObject({ subagent_phase: 'working', started_at: startedAt }); - expect(t.get(SID)[0]?.suspended_reason).toBeUndefined(); - - t.apply( - SID, - ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' }), - ); - expect(t.get(SID)[0]).toMatchObject({ - subagent_phase: 'completed', - status: 'completed', - output_preview: 'done', - }); - expect(t.get(SID)[0]?.completed_at).toBeDefined(); - }); - - it('marks failures with the error preview', () => { - const t = new SubagentRosterTracker(); - t.apply(SID, spawn('agent-1')); - t.apply(SID, ev({ type: 'subagent.failed', subagentId: 'agent-1', error: 'boom' })); - expect(t.get(SID)[0]).toMatchObject({ - subagent_phase: 'failed', - status: 'failed', - output_preview: 'boom', - }); - }); - - it('clears the roster on the next MAIN turn.started, not on any turn.ended', () => { - const t = new SubagentRosterTracker(); - t.apply(SID, spawn('agent-1')); - - t.apply(SID, ev({ type: 'turn.ended', agentId: 'agent-1', turnId: 1 })); - expect(t.get(SID)).toHaveLength(1); - - t.apply(SID, ev({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' })); - expect(t.get(SID)).toHaveLength(1); - - t.apply(SID, ev({ type: 'turn.started', agentId: 'main', turnId: 2 })); - expect(t.get(SID)).toEqual([]); - }); - - it('finalizes still-live entries when the main turn aborts', () => { - const t = new SubagentRosterTracker(); - t.apply(SID, spawn('agent-1')); - t.apply(SID, spawn('agent-2')); - t.apply(SID, ev({ type: 'subagent.completed', subagentId: 'agent-2', resultSummary: 'done' })); - - t.apply(SID, ev({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'cancelled' })); - - const entries = t.get(SID); - expect(entries[0]).toMatchObject({ - id: 'agent-1', - status: 'failed', - subagent_phase: 'failed', - output_preview: 'Main turn cancelled', - }); - expect(entries[0]?.completed_at).toBeDefined(); - expect(entries[1]).toMatchObject({ id: 'agent-2', status: 'completed', output_preview: 'done' }); - }); - - it('ignores lifecycle events for unknown subagents', () => { - const t = new SubagentRosterTracker(); - t.apply(SID, ev({ type: 'subagent.started', subagentId: 'ghost' })); - t.apply(SID, ev({ type: 'subagent.completed', subagentId: 'ghost', resultSummary: 'x' })); - expect(t.get(SID)).toEqual([]); - }); - - it('returns fresh copies that callers cannot mutate back into the tracker', () => { - const t = new SubagentRosterTracker(); - t.apply(SID, spawn('agent-1')); - const first = t.get(SID); - first[0]!.description = 'mutated'; - expect(t.get(SID)[0]?.description).toBe('task agent-1'); - }); -}); diff --git a/packages/kap-server/test/transcript.test.ts b/packages/kap-server/test/transcript.test.ts deleted file mode 100644 index df8fe96055e..00000000000 --- a/packages/kap-server/test/transcript.test.ts +++ /dev/null @@ -1,1437 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { - IAgentContextMemoryService, - IAgentLifecycleService, - IWireService, - IEventBus, - closeSessionById, - getLiveSessionById, - interactions, - resumeSessionById, - IModelCatalog, - type ContextMessage, - type Event2, - type ScopeSeed, -} from '@moonshot-ai/agent-core-v2'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; - -import { type RunningServer, startServer } from '../src/start'; -import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; -import { authHeaders } from './helpers/auth'; - -interface Envelope { - code: number; - msg: string; - data: T; - request_id: string; - details?: { path: string; message: string }[]; -} - -interface FrameContract { - kind: string; - text?: string; - state?: string; - toolCallId?: string; - interactionKind?: string; - [key: string]: unknown; -} - -interface TurnContract { - kind: 'turn'; - turnId: string; - state: string; - origin?: { kind: string }; - prompt?: string; - steps: { stepId: string; state: string; frames: FrameContract[] }[]; -} - -interface TranscriptContract { - agent_id: string; - items: (TurnContract | { kind: 'marker' | 'taskref' })[]; - has_more: boolean; - tasks: unknown[]; - interactions: { - interactionId: string; - interactionKind?: string; - toolCallId?: string; - state: string; - [key: string]: unknown; - }[]; - prompts: { - promptId: string; - status: string; - userMessageId?: string; - content?: unknown; - createdAt?: string; - [key: string]: unknown; - }[]; - meta: Record; - agents: { agentId: string; type?: string }[]; - pending_interactions: string[]; - seq?: number; -} - -interface OpsCatchupContract { - agent_id: string; - batches: { seq: number; ops: { op: string }[] }[]; - latest_seq: number; - complete: boolean; -} - -interface UserMessagesContract { - agents: { - agent_id: string; - messages: { - turn_id: string; - ordinal: number; - state: string; - origin: { kind: string }; - prompt: string; - attachment_ids?: string[]; - started_at?: string; - }[]; - attachments: { attachmentId: string; mediaType: string; source?: unknown }[]; - }[]; -} - -interface PlanEntryContract { - tool_call_id: string; - turn_id: string; - source: 'interaction' | 'display' | 'output'; - plan: string; - path?: string; - options?: { label: string; description?: string }[]; - review?: { state: string; selected_option?: string; feedback?: string }; -} - -interface PlanContract { - agent_id: string; - plans: PlanEntryContract[]; -} - -function serverEvent(payload: Record): Event2 { - return payload as unknown as Event2; -} - -describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { - let server: RunningServer | undefined; - let home: string | undefined; - let base: string; - let seeds: ScopeSeed | undefined; - - beforeAll(async () => { - home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-transcript-')); - const modelCatalog: IModelCatalog = { - _serviceBrand: undefined, - get: () => { - throw new Error('modelCatalog.get not exercised in this test'); - }, - getRequester: () => { - throw new Error('modelCatalog.getRequester not exercised in this test'); - }, - generate: () => { - throw new Error('modelCatalog.generate not exercised in this test'); - }, - ping: () => { - throw new Error('modelCatalog.ping not exercised in this test'); - }, - findByName: () => [], - listModels: async () => [], - listProviders: async () => [], - getProvider: async () => { - throw new Error('modelCatalog.getProvider not exercised in this test'); - }, - setDefaultModel: async () => { - throw new Error('modelCatalog.setDefaultModel not exercised in this test'); - }, - }; - seeds = [[IModelCatalog, modelCatalog]]; - await boot(); - }); - - async function boot(): Promise { - server = await startServer({ - hostIdentity: TEST_HOST_IDENTITY, - host: '127.0.0.1', - port: 0, - homeDir: home as string, - logLevel: 'silent', - seeds, - }); - base = `http://127.0.0.1:${server.port}`; - } - - afterAll(async () => { - if (server !== undefined) { - await server.close(); - server = undefined; - } - if (home !== undefined) { - await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); - home = undefined; - } - }); - - async function getJson(path: string): Promise<{ status: number; body: Envelope }> { - const res = await fetch(`${base}${path}`, { - headers: authHeaders(server as RunningServer), - } as never); - return { status: res.status, body: (await res.json()) as Envelope }; - } - - async function createSession(): Promise { - const res = await fetch(`${base}/api/v1/sessions`, { - method: 'POST', - headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), - body: JSON.stringify({ metadata: { cwd: home as string } }), - } as never); - const body = (await res.json()) as Envelope<{ id: string }>; - expect(body.code).toBe(0); - return body.data.id; - } - - async function ensureMainAgent(sessionId: string): Promise { - const session = getLiveSessionById(server!.core.accessor, sessionId); - if (session === undefined) throw new Error(`session ${sessionId} not found`); - if (session.accessor.get(IAgentLifecycleService).handleOf('main') === undefined) { - await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); - } - } - - function mainAgentBus(sessionId: string): IEventBus { - const session = getLiveSessionById(server!.core.accessor, sessionId); - const agent = session!.accessor.get(IAgentLifecycleService).handleOf('main'); - return agent!.accessor.get(IEventBus); - } - - async function seedMainAgentMessages( - sessionId: string, - messages: readonly ContextMessage[], - ): Promise { - const session = getLiveSessionById(server!.core.accessor, sessionId); - const agent = session!.accessor.get(IAgentLifecycleService).handleOf('main'); - agent!.accessor.get(IAgentContextMemoryService).append(...messages); - await agent!.accessor.get(IWireService).flush(); - } - - it('streams a live turn tree: deltas flush into full-text frames at step end', async () => { - const id = await createSession(); - await ensureMainAgent(id); - - const empty = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - expect(empty.body.code).toBe(0); - expect(empty.body.data.items).toEqual([]); - expect(empty.body.data.has_more).toBe(false); - - const bus = mainAgentBus(id); - bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 })); - bus.publish(serverEvent({ type: 'assistant.delta', turnId: 1, delta: 'Hello' })); - bus.publish(serverEvent({ type: 'assistant.delta', turnId: 1, delta: ' world' })); - bus.publish( - serverEvent({ - type: 'tool.call.started', - turnId: 1, - toolCallId: 'call_1', - name: 'Bash', - args: { command: 'ls' }, - }), - ); - bus.publish(serverEvent({ type: 'tool.result', turnId: 1, toolCallId: 'call_1', output: 'a.txt' })); - bus.publish(serverEvent({ type: 'turn.step.completed', turnId: 1, step: 1 })); - bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=main`, - ); - expect(body.code).toBe(0); - const turn = body.data.items.find( - (item): item is TurnContract => item.kind === 'turn' && item.turnId === 't1', - ); - expect(turn).toBeDefined(); - expect(turn!.state).toBe('completed'); - expect(turn!.steps).toHaveLength(1); - const frames = turn!.steps[0]!.frames; - expect(frames).toContainEqual( - expect.objectContaining({ kind: 'text', text: 'Hello world' }), - ); - expect(frames).toContainEqual( - expect.objectContaining({ - kind: 'tool', - toolCallId: 'call_1', - state: 'done', - output: 'a.txt', - }), - ); - await vi.waitFor(async () => { - const again = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=main`, - ); - expect(again.body.data.agents).toContainEqual({ agentId: 'main', type: 'main' }); - }); - }); - - it('surfaces approval interactions as global entities with pending ids', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - - const bus = mainAgentBus(id); - bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 })); - bus.publish( - serverEvent({ - type: 'tool.call.started', - turnId: 1, - toolCallId: 'call_9', - name: 'Bash', - args: {}, - }), - ); - - interactions.enqueue({ - id: 'apr-1', - kind: 'approval', - payload: { - toolCallId: 'call_9', - toolName: 'Bash', - action: 'run', - display: { kind: 'command', command: 'ls' }, - }, - tags: { agentId: 'main', sessionId: id, turnId: 1 }, - }); - - let { body } = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - expect(body.data.pending_interactions).toEqual(['apr-1']); - expect(body.data.interactions).toContainEqual( - expect.objectContaining({ - interactionId: 'apr-1', - interactionKind: 'approval', - toolCallId: 'call_9', - state: 'pending', - }), - ); - - interactions.respond('apr-1', { decision: 'approved' }); - ({ body } = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`)); - expect(body.data.pending_interactions).toEqual([]); - expect(body.data.interactions).toContainEqual( - expect.objectContaining({ interactionId: 'apr-1', state: 'approved' }), - ); - const frames = (body.data.items[0] as TurnContract).steps[0]!.frames; - expect(frames).toContainEqual( - expect.objectContaining({ kind: 'tool', toolCallId: 'call_9', approvalId: 'apr-1' }), - ); - }); - - it('exposes the prompt queue entities in the live transcript response', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - - const bus = mainAgentBus(id); - bus.publish( - serverEvent({ - type: 'prompt.submitted', - promptId: 'p1', - userMessageId: 'p1', - status: 'running', - content: [{ type: 'text', text: 'first' }], - createdAt: '2026-01-01T00:00:00.000Z', - }), - ); - bus.publish( - serverEvent({ - type: 'prompt.submitted', - promptId: 'p2', - userMessageId: 'p2', - status: 'queued', - content: [{ type: 'text', text: 'second' }], - createdAt: '2026-01-01T00:00:01.000Z', - }), - ); - - let { body } = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - expect(body.data.prompts).toContainEqual( - expect.objectContaining({ - promptId: 'p1', - status: 'running', - userMessageId: 'p1', - content: [{ type: 'text', text: 'first' }], - }), - ); - expect(body.data.prompts).toContainEqual(expect.objectContaining({ promptId: 'p2', status: 'queued' })); - - bus.publish(serverEvent({ type: 'prompt.started', promptId: 'p2' })); - ({ body } = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`)); - expect(body.data.prompts).toContainEqual( - expect.objectContaining({ - promptId: 'p2', - status: 'running', - content: [{ type: 'text', text: 'second' }], - }), - ); - }); - - it('paginates live turns with page_size and before_turn', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - - const bus = mainAgentBus(id); - for (const turnId of [1, 2, 3]) { - bus.publish(serverEvent({ type: 'turn.started', turnId, origin: { kind: 'user' } })); - bus.publish(serverEvent({ type: 'turn.ended', turnId, reason: 'completed' })); - } - - const page = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=main&page_size=2`, - ); - expect(page.body.data.items.map((item) => (item as TurnContract).turnId)).toEqual(['t2', 't3']); - expect(page.body.data.has_more).toBe(true); - - const older = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=main&page_size=2&before_turn=t3`, - ); - expect(older.body.data.items.map((item) => (item as TurnContract).turnId)).toEqual(['t1', 't2']); - expect(older.body.data.has_more).toBe(false); - - const unknown = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=nope`, - ); - expect(unknown.body.code).toBe(0); - expect(unknown.body.data.items).toEqual([]); - }); - - it('rebuilds the main agent for a cold session from the wire records', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, - { - role: 'assistant', - content: [{ type: 'text', text: 'running' }], - toolCalls: [{ type: 'function', id: 'call_1', name: 'Bash', arguments: '{"cmd":"ls"}' }], - }, - { - role: 'tool', - content: [{ type: 'text', text: 'file.txt' }], - toolCalls: [], - toolCallId: 'call_1', - }, - ]); - - await server!.close(); - server = undefined; - await boot(); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=main`, - ); - expect(body.code).toBe(0); - expect(body.data.has_more).toBe(false); - expect(body.data.agents).toEqual([{ agentId: 'main', type: 'main' }]); - expect(body.data.pending_interactions).toEqual([]); - - const turn = body.data.items.find( - (item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0', - ); - expect(turn).toBeDefined(); - expect(turn!.state).toBe('completed'); - expect(turn!.prompt).toBe('hi'); - const frames = turn!.steps[0]!.frames; - expect(frames).toContainEqual(expect.objectContaining({ kind: 'text', text: 'running' })); - expect(frames).toContainEqual( - expect.objectContaining({ - kind: 'tool', - toolCallId: 'call_1', - state: 'done', - output: 'file.txt', - }), - ); - - const sub = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=sub-1`); - expect(sub.body.code).toBe(0); - expect(sub.body.data.items).toEqual([]); - expect(sub.body.data.has_more).toBe(false); - }); - - it('backfills a resumed live session from the wire records, then continues live', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, - { role: 'assistant', content: [{ type: 'text', text: 'running' }], toolCalls: [] }, - ]); - - await server!.close(); - server = undefined; - await boot(); - await resumeSessionById(server!.core.accessor, id); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=main`, - ); - expect(body.code).toBe(0); - const turn = body.data.items.find( - (item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0', - ); - expect(turn).toBeDefined(); - expect(turn!.state).toBe('completed'); - expect(turn!.prompt).toBe('hi'); - - const bus = mainAgentBus(id); - bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - const again = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=main`, - ); - expect(again.body.data.items.map((item) => (item as TurnContract).turnId)).toEqual(['t0', 't1']); - }); - - it('rebuilds a subagent for a cold session from its own wire records', async () => { - const id = await createSession(); - await ensureMainAgent(id); - const session = getLiveSessionById(server!.core.accessor, id); - await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); - const sub = session!.accessor.get(IAgentLifecycleService).handleOf('sub-1')!; - sub.accessor - .get(IAgentContextMemoryService) - .append( - { role: 'user', content: [{ type: 'text', text: 'scan the repo' }], toolCalls: [] } as ContextMessage, - { role: 'assistant', content: [{ type: 'text', text: 'scanning' }], toolCalls: [] } as ContextMessage, - ); - await sub.accessor.get(IWireService).flush(); - - await server!.close(); - server = undefined; - await boot(); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=sub-1`, - ); - expect(body.code).toBe(0); - const turn = body.data.items.find( - (item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0', - ); - expect(turn).toBeDefined(); - expect(turn!.prompt).toBe('scan the repo'); - const none = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=nope`); - expect(none.body.code).toBe(0); - expect(none.body.data.items).toEqual([]); - }); - - it('backfills an unmaterialized subagent for a resumed live session', async () => { - const id = await createSession(); - await ensureMainAgent(id); - const session = getLiveSessionById(server!.core.accessor, id); - await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); - const sub = session!.accessor.get(IAgentLifecycleService).handleOf('sub-1')!; - sub.accessor - .get(IAgentContextMemoryService) - .append( - { role: 'user', content: [{ type: 'text', text: 'scan the repo' }], toolCalls: [] } as ContextMessage, - { role: 'assistant', content: [{ type: 'text', text: 'scanning' }], toolCalls: [] } as ContextMessage, - ); - await sub.accessor.get(IWireService).flush(); - - await server!.close(); - server = undefined; - await boot(); - await resumeSessionById(server!.core.accessor, id); - expect( - getLiveSessionById(server!.core.accessor, id)! - .accessor.get(IAgentLifecycleService) - .handleOf('sub-1'), - ).toBeUndefined(); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=sub-1`, - ); - expect(body.code).toBe(0); - const turn = body.data.items.find( - (item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0', - ); - expect(turn).toBeDefined(); - expect(turn!.prompt).toBe('scan the repo'); - expect(body.data.agents).toContainEqual(expect.objectContaining({ agentId: 'sub-1' })); - }); - - it('keeps the metadata-seeded subagent descriptor after an on-demand backfill', async () => { - const id = await createSession(); - await ensureMainAgent(id); - const session = getLiveSessionById(server!.core.accessor, id); - await session!.accessor - .get(IAgentLifecycleService) - .create({ agentId: 'sub-1', labels: { parentAgentId: 'main' } }); - const sub = session!.accessor.get(IAgentLifecycleService).handleOf('sub-1')!; - sub.accessor - .get(IAgentContextMemoryService) - .append( - { role: 'user', content: [{ type: 'text', text: 'scan the repo' }], toolCalls: [] } as ContextMessage, - ); - await sub.accessor.get(IWireService).flush(); - - await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - const { body } = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=sub-1`); - expect(body.code).toBe(0); - expect(body.data.agents).toContainEqual( - expect.objectContaining({ agentId: 'sub-1', type: 'sub', parentAgentId: 'main' }), - ); - }); - - it('announces a pre-existing pending approval against the backfilled tool frame', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'run ls' }], toolCalls: [] }, - { - role: 'assistant', - content: [{ type: 'text', text: 'running' }], - toolCalls: [{ type: 'function', id: 'call_9', name: 'Bash', arguments: '{"command":"ls"}' }], - }, - ]); - - interactions.enqueue({ - id: 'apr-1', - kind: 'approval', - payload: { toolCallId: 'call_9', toolName: 'Bash', action: 'run' }, - tags: { agentId: 'main', sessionId: id, turnId: 0 }, - }); - - const { body } = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - expect(body.data.pending_interactions).toEqual(['apr-1']); - expect(body.data.interactions).toContainEqual( - expect.objectContaining({ - interactionId: 'apr-1', - interactionKind: 'approval', - toolCallId: 'call_9', - state: 'pending', - }), - ); - - interactions.respond('apr-1', { decision: 'approved' }); - const after = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - const turnAfter = after.body.data.items.find( - (item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0', - ); - expect(turnAfter!.steps.flatMap((step) => step.frames)).toContainEqual( - expect.objectContaining({ kind: 'tool', toolCallId: 'call_9', approvalId: 'apr-1' }), - ); - }); - - it('does not roster a ghost agent for an unknown agent id on a live session', async () => { - const id = await createSession(); - await ensureMainAgent(id); - - const none = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=nope`); - expect(none.body.code).toBe(0); - expect(none.body.data.items).toEqual([]); - - const main = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - expect(main.body.data.agents.map((a) => a.agentId)).not.toContain('nope'); - }); - - it('seeds a subagent pending question only after its own backfill', async () => { - const id = await createSession(); - await ensureMainAgent(id); - const session = getLiveSessionById(server!.core.accessor, id); - await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); - const sub = session!.accessor.get(IAgentLifecycleService).handleOf('sub-1')!; - sub.accessor - .get(IAgentContextMemoryService) - .append( - { role: 'user', content: [{ type: 'text', text: 'scan' }], toolCalls: [] } as ContextMessage, - { - role: 'assistant', - content: [], - toolCalls: [{ type: 'function', id: 'call_q', name: 'AskUserQuestion', arguments: '{}' }], - } as ContextMessage, - ); - await sub.accessor.get(IWireService).flush(); - - const pending = interactions.request({ - id: 'call_q', - kind: 'question', - payload: { - turnId: 0, - toolCallId: 'call_q', - questions: [{ question: 'Pick?', options: [{ label: 'A' }] }], - }, - tags: { agentId: 'sub-1', sessionId: id, turnId: 0, toolCallId: 'call_q' }, - }); - - const mainBody = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - expect(mainBody.body.data.pending_interactions).toEqual([]); - - const subBody = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=sub-1`); - expect(subBody.body.data.pending_interactions).toEqual(['call_q']); - expect(subBody.body.data.interactions).toContainEqual( - expect.objectContaining({ - interactionId: 'call_q', - interactionKind: 'question', - toolCallId: 'call_q', - state: 'pending', - }), - ); - - interactions.respond('call_q', null); - await pending; - }); - - it('does not fabricate a roster entry for an unknown agent on a cold session', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, - { role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, - ]); - - await server!.close(); - server = undefined; - await boot(); - - const none = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=nope`); - expect(none.body.code).toBe(0); - expect(none.body.data.items).toEqual([]); - expect(none.body.data.agents.map((a) => a.agentId)).not.toContain('nope'); - expect(none.body.data.agents).toContainEqual({ agentId: 'main', type: 'main' }); - }); - - it('returns 40401 for an unknown session', async () => { - const { body } = await getJson('/api/v1/sessions/nope/transcript?agent_id=main'); - expect(body.code).toBe(40401); - }); - - it('drops the live store when the session closes so reads fall back to the cold rebuild', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, - { role: 'assistant', content: [{ type: 'text', text: 'hello there' }], toolCalls: [] }, - ]); - - const bound = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - expect(bound.body.data.items).toHaveLength(1); - - const bus = mainAgentBus(id); - bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - - await closeSessionById(server!.core.accessor, id); - - const { body } = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - expect(body.code).toBe(0); - expect(body.data.items.map((item) => (item as TurnContract).turnId)).toEqual(['t0']); - const turn = body.data.items[0] as TurnContract; - expect(turn.prompt).toBe('hi'); - expect(turn.steps[0]!.frames).toContainEqual( - expect.objectContaining({ kind: 'text', text: 'hello there' }), - ); - }); - - it('heals the missing stream prefix after a mid-turn attach once the turn ends', async () => { - const id = await createSession(); - await ensureMainAgent(id); - - const bus = mainAgentBus(id); - bus.publish( - serverEvent({ type: 'turn.started', turnId: 0, origin: { kind: 'user' }, prompt: 'hi' }), - ); - bus.publish(serverEvent({ type: 'turn.step.started', turnId: 0, step: 1 })); - bus.publish(serverEvent({ type: 'assistant.delta', turnId: 0, delta: 'Hello ' })); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, - ]); - - await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - bus.publish(serverEvent({ type: 'assistant.delta', turnId: 0, delta: 'world' })); - const suffix = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - const suffixTurn = suffix.body.data.items.find( - (item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0', - ); - expect(suffixTurn!.steps[0]!.frames).toContainEqual( - expect.objectContaining({ kind: 'text', text: 'world' }), - ); - - await seedMainAgentMessages(id, [ - { role: 'assistant', content: [{ type: 'text', text: 'Hello world' }], toolCalls: [] }, - ]); - bus.publish(serverEvent({ type: 'turn.step.completed', turnId: 0, step: 1 })); - bus.publish(serverEvent({ type: 'turn.ended', turnId: 0, reason: 'completed' })); - - await vi.waitFor( - async () => { - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=main`, - ); - const turn = body.data.items.find( - (item): item is TurnContract => item.kind === 'turn' && item.turnId === 't0', - ); - expect(turn).toBeDefined(); - expect(turn!.origin).toMatchObject({ kind: 'user' }); - expect(turn!.prompt).toBe('hi'); - expect(turn!.steps[0]!.frames).toContainEqual( - expect.objectContaining({ kind: 'text', text: 'Hello world' }), - ); - }, - { timeout: 5000, interval: 50 }, - ); - }); - - it('routes a subagent question to the subagent transcript, not main', async () => { - const id = await createSession(); - await ensureMainAgent(id); - const session = getLiveSessionById(server!.core.accessor, id); - await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); - const sub = session!.accessor.get(IAgentLifecycleService).handleOf('sub-1')!; - - await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - - const subBus = sub.accessor.get(IEventBus); - subBus.publish( - serverEvent({ type: 'turn.started', turnId: 0, origin: { kind: 'task', taskId: 'task-1' } }), - ); - subBus.publish(serverEvent({ type: 'turn.step.started', turnId: 0, step: 1 })); - subBus.publish( - serverEvent({ - type: 'tool.call.started', - turnId: 0, - toolCallId: 'call_q', - name: 'AskUserQuestion', - args: {}, - }), - ); - - const pending = interactions.request({ - id: 'call_q', - kind: 'question', - payload: { - turnId: 0, - toolCallId: 'call_q', - questions: [{ question: 'Pick one?', options: [{ label: 'A' }, { label: 'B' }] }], - }, - tags: { agentId: 'sub-1', sessionId: id, turnId: 0, toolCallId: 'call_q' }, - }); - - const subBody = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=sub-1`); - expect(subBody.body.data.pending_interactions).toEqual(['call_q']); - expect(subBody.body.data.interactions).toContainEqual( - expect.objectContaining({ - interactionId: 'call_q', - interactionKind: 'question', - toolCallId: 'call_q', - state: 'pending', - }), - ); - - const mainBody = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - expect(mainBody.body.data.pending_interactions).toEqual([]); - - interactions.respond('call_q', null); - await pending; - }); - - it('rejects path-hostile agent ids with 40001', async () => { - const id = await createSession(); - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=${encodeURIComponent('../main')}`, - ); - expect(body.code).toBe(40001); - }); - - it('rejects before_turn + after_turn together with 40001', async () => { - const id = await createSession(); - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript?agent_id=main&before_turn=t2&after_turn=t1`, - ); - expect(body.code).toBe(40001); - }); - - it('carries the op-batch watermark on the live transcript response', async () => { - const id = await createSession(); - await ensureMainAgent(id); - - const bound = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - expect(bound.body.data.seq).toBeTypeOf('number'); - const base = bound.body.data.seq!; - - const bus = mainAgentBus(id); - bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - - const after = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - expect(after.body.data.seq).toBeGreaterThan(base); - }); - - it('serves catch-up batches with seq > since_seq on the ops route', async () => { - const id = await createSession(); - await ensureMainAgent(id); - - const bound = await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - const base = bound.body.data.seq!; - - const bus = mainAgentBus(id); - bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - - const catchup = await getJson( - `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=${base}`, - ); - expect(catchup.body.code).toBe(0); - expect(catchup.body.data.complete).toBe(true); - expect(catchup.body.data.latest_seq).toBeGreaterThan(base); - const seqs = catchup.body.data.batches.map((batch) => batch.seq); - expect(seqs.every((seq) => seq > base)).toBe(true); - expect(seqs).toEqual(seqs.map((_, i) => seqs[0]! + i)); - expect( - catchup.body.data.batches.some((batch) => batch.ops.some((op) => op.op === 'turn.upsert')), - ).toBe(true); - - const current = await getJson( - `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=${catchup.body.data.latest_seq}`, - ); - expect(current.body.data).toMatchObject({ batches: [], complete: true }); - - const stale = await getJson( - `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=99999`, - ); - expect(stale.body.data.complete).toBe(false); - }); - - it('answers complete:false for a cold session and 40401 for an unknown one on the ops route', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, - ]); - - await server!.close(); - server = undefined; - await boot(); - - const cold = await getJson( - `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=0`, - ); - expect(cold.body.code).toBe(0); - expect(cold.body.data).toMatchObject({ batches: [], complete: false }); - - const missing = await getJson( - `/api/v1/sessions/nope/transcript/ops?agent_id=main&since_seq=0`, - ); - expect(missing.body.code).toBe(40401); - }); - - it('rejects invalid since_seq / agent_id on the ops route with 40001', async () => { - const id = await createSession(); - const negative = await getJson( - `/api/v1/sessions/${id}/transcript/ops?agent_id=main&since_seq=-1`, - ); - expect(negative.body.code).toBe(40001); - const hostile = await getJson( - `/api/v1/sessions/${id}/transcript/ops?agent_id=${encodeURIComponent('../main')}&since_seq=0`, - ); - expect(hostile.body.code).toBe(40001); - }); - - it('serves every prompted turn for one agent on the user-messages route (live)', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - - const bus = mainAgentBus(id); - bus.publish( - serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'first' }), - ); - bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - bus.publish( - serverEvent({ type: 'turn.started', turnId: 2, origin: { kind: 'task', taskId: 'task-1' } }), - ); - bus.publish(serverEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' })); - bus.publish( - serverEvent({ type: 'turn.started', turnId: 3, origin: { kind: 'user' }, prompt: 'second' }), - ); - bus.publish(serverEvent({ type: 'turn.ended', turnId: 3, reason: 'completed' })); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript/user-messages?agent_id=main`, - ); - expect(body.code).toBe(0); - expect(body.data.agents).toHaveLength(1); - const main = body.data.agents[0]!; - expect(main.agent_id).toBe('main'); - expect(main.messages.map((m) => [m.turn_id, m.prompt])).toEqual([ - ['t1', 'first'], - ['t3', 'second'], - ]); - expect(main.messages[0]).toMatchObject({ ordinal: 1, state: 'completed' }); - expect(main.messages[0]!.origin).toMatchObject({ kind: 'user' }); - expect(main.attachments).toEqual([]); - }); - - it('serves per-agent user messages for every rostered agent when agent_id is omitted (live)', async () => { - const id = await createSession(); - await ensureMainAgent(id); - const session = getLiveSessionById(server!.core.accessor, id); - await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); - const sub = session!.accessor.get(IAgentLifecycleService).handleOf('sub-1')!; - sub.accessor - .get(IAgentContextMemoryService) - .append({ role: 'user', content: [{ type: 'text', text: 'scan the repo' }], toolCalls: [] } as ContextMessage); - await sub.accessor.get(IWireService).flush(); - - const bound = await getJson(`/api/v1/sessions/${id}/transcript/user-messages`); - const boundByAgent = new Map(bound.body.data.agents.map((a) => [a.agent_id, a])); - expect(boundByAgent.get('main')!.messages).toEqual([]); - expect(boundByAgent.get('sub-1')!.messages.map((m) => m.prompt)).toEqual(['scan the repo']); - - const bus = mainAgentBus(id); - bus.publish( - serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'hello main' }), - ); - bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript/user-messages`, - ); - expect(body.code).toBe(0); - const byAgent = new Map(body.data.agents.map((a) => [a.agent_id, a])); - expect(byAgent.get('main')!.messages.map((m) => m.prompt)).toEqual(['hello main']); - expect(byAgent.get('sub-1')!.messages.map((m) => m.prompt)).toEqual(['scan the repo']); - }); - - it('rebuilds per-agent user messages for a cold session, folding hidden origins', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, - { role: 'assistant', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, - { - role: 'user', - content: [{ type: 'text', text: 'injected reminder' }], - toolCalls: [], - origin: { kind: 'injection', variant: 'reminder' }, - } as ContextMessage, - { - role: 'user', - content: [{ type: 'text', text: 'subagent run prompt' }], - toolCalls: [], - origin: { kind: 'system_trigger', name: 'subagent' }, - } as ContextMessage, - { - role: 'user', - content: [ - { type: 'text', text: 'second question' }, - { type: 'image', source: { kind: 'url', url: 'https://example.com/a.png' } }, - ], - toolCalls: [], - } as ContextMessage, - ]); - const session = getLiveSessionById(server!.core.accessor, id); - await session!.accessor.get(IAgentLifecycleService).create({ agentId: 'sub-1' }); - const sub = session!.accessor.get(IAgentLifecycleService).handleOf('sub-1')!; - sub.accessor - .get(IAgentContextMemoryService) - .append({ role: 'user', content: [{ type: 'text', text: 'scan the repo' }], toolCalls: [] } as ContextMessage); - await sub.accessor.get(IWireService).flush(); - - await server!.close(); - server = undefined; - await boot(); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript/user-messages`, - ); - expect(body.code).toBe(0); - const byAgent = new Map(body.data.agents.map((a) => [a.agent_id, a])); - - const main = byAgent.get('main')!; - expect(main.messages.map((m) => [m.turn_id, m.prompt])).toEqual([ - ['t0', 'hi'], - ['t1', 'subagent run prompt'], - ['t2', 'second question'], - ]); - expect(main.messages[2]!.attachment_ids).toEqual(['att_1']); - expect(main.attachments).toEqual([ - expect.objectContaining({ - attachmentId: 'att_1', - mediaType: 'image/*', - source: { kind: 'url', url: 'https://example.com/a.png' }, - }), - ]); - - expect(byAgent.get('sub-1')!.messages.map((m) => m.prompt)).toEqual(['scan the repo']); - - const single = await getJson( - `/api/v1/sessions/${id}/transcript/user-messages?agent_id=main`, - ); - expect(single.body.data.agents.map((a) => a.agent_id)).toEqual(['main']); - }); - - it('lists an attachment-only prompt as an empty-string user message (live)', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - - const bus = mainAgentBus(id); - bus.publish( - serverEvent({ - type: 'turn.started', - turnId: 1, - origin: { kind: 'user' }, - promptAttachments: [{ kind: 'image', fileId: 'f_upload' }], - }), - ); - bus.publish(serverEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript/user-messages?agent_id=main`, - ); - expect(body.code).toBe(0); - const main = body.data.agents[0]!; - expect(main.messages.map((m) => [m.turn_id, m.prompt])).toEqual([['t1', '']]); - expect(main.messages[0]!.attachment_ids).toEqual(['t1.att1']); - expect(main.attachments).toEqual([ - expect.objectContaining({ - attachmentId: 't1.att1', - mediaType: 'image/*', - source: { kind: 'session_media', fileId: 'f_upload' }, - }), - ]); - }); - - it('lists an attachment-only prompt as an empty-string user message (cold)', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await seedMainAgentMessages(id, [ - { - role: 'user', - content: [ - { type: 'image_url', imageUrl: { url: 'kimi-file://f_upload?path=%2Ftmp%2Fcache%2Ff_upload.png' } }, - ], - toolCalls: [], - } as ContextMessage, - { role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] }, - ]); - - await server!.close(); - server = undefined; - await boot(); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript/user-messages?agent_id=main`, - ); - expect(body.code).toBe(0); - const main = body.data.agents[0]!; - expect(main.messages.map((m) => [m.turn_id, m.prompt])).toEqual([['t0', '']]); - expect(main.messages[0]!.attachment_ids).toEqual(['att_1']); - expect(main.attachments).toEqual([ - expect.objectContaining({ - attachmentId: 'att_1', - mediaType: 'image/*', - source: { kind: 'session_media', fileId: 'f_upload' }, - }), - ]); - }); - - it('answers 40401 for an unknown session and 40001 for a hostile agent id on the user-messages route', async () => { - const missing = await getJson('/api/v1/sessions/nope/transcript/user-messages'); - expect(missing.body.code).toBe(40401); - - const id = await createSession(); - const hostile = await getJson( - `/api/v1/sessions/${id}/transcript/user-messages?agent_id=${encodeURIComponent('../main')}`, - ); - expect(hostile.body.code).toBe(40001); - }); - - it('serves plan info for an ExitPlanMode call from its approval interaction (live)', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - - const bus = mainAgentBus(id); - bus.publish( - serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' }, prompt: 'build it' }), - ); - bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 })); - bus.publish( - serverEvent({ - type: 'tool.call.started', - turnId: 1, - toolCallId: 'call_plan', - name: 'ExitPlanMode', - args: {}, - }), - ); - - const planDisplay = { - kind: 'plan_review', - plan: '# The Plan\n\nDo the thing.', - path: '/tmp/plans/foo.md', - options: [{ label: 'Approach A', description: 'fast' }], - }; - interactions.enqueue({ - id: 'apr-plan', - kind: 'approval', - payload: { - toolCallId: 'call_plan', - toolName: 'ExitPlanMode', - action: 'Presenting plan and exiting plan mode', - display: planDisplay, - }, - tags: { agentId: 'main', sessionId: id, turnId: 1 }, - }); - interactions.respond('apr-plan', { decision: 'approved', selectedLabel: 'Approach A' }); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript/plan?agent_id=main&tool_call_id=call_plan`, - ); - expect(body.code).toBe(0); - expect(body.data.agent_id).toBe('main'); - expect(body.data.plans).toHaveLength(1); - expect(body.data.plans[0]).toMatchObject({ - tool_call_id: 'call_plan', - turn_id: 't1', - source: 'interaction', - plan: '# The Plan\n\nDo the thing.', - path: '/tmp/plans/foo.md', - options: [{ label: 'Approach A', description: 'fast' }], - review: { state: 'approved', selected_option: 'Approach A' }, - }); - }); - - it('serves plan info from the live tool frame display when no interaction exists (auto mode)', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - - const bus = mainAgentBus(id); - bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 })); - bus.publish( - serverEvent({ - type: 'tool.call.started', - turnId: 1, - toolCallId: 'call_plan', - name: 'ExitPlanMode', - args: {}, - display: { kind: 'plan_review', plan: '# Auto Plan', path: '/tmp/plans/auto.md' }, - }), - ); - bus.publish( - serverEvent({ - type: 'tool.result', - turnId: 1, - toolCallId: 'call_plan', - output: - 'Exited plan mode. Plan mode deactivated. All tools are now available.\nPlan saved to: /tmp/plans/auto.md\n\n## Plan (auto-approved, not user-reviewed):\n# Auto Plan', - }), - ); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript/plan?agent_id=main&tool_call_id=call_plan`, - ); - expect(body.code).toBe(0); - expect(body.data.plans).toHaveLength(1); - expect(body.data.plans[0]).toMatchObject({ - source: 'display', - plan: '# Auto Plan', - path: '/tmp/plans/auto.md', - }); - expect(body.data.plans[0]!.review).toBeUndefined(); - }); - - it('rebuilds plan info from the tool result output for a cold session', async () => { - const id = await createSession(); - await ensureMainAgent(id); - const output = - 'Exited plan mode. Plan mode deactivated. All tools are now available.\nPlan saved to: /tmp/plans/foo.md\n\n## Approved Plan:\n# The Plan\n\nDo the thing.'; - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'build it' }], toolCalls: [] }, - { - role: 'assistant', - content: [], - toolCalls: [{ type: 'function', id: 'call_plan', name: 'ExitPlanMode', arguments: '{}' }], - }, - { - role: 'tool', - content: [{ type: 'text', text: output }], - toolCalls: [], - toolCallId: 'call_plan', - }, - ]); - - await server!.close(); - server = undefined; - await boot(); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript/plan?agent_id=main&tool_call_id=call_plan`, - ); - expect(body.code).toBe(0); - expect(body.data.agent_id).toBe('main'); - expect(body.data.plans).toHaveLength(1); - expect(body.data.plans[0]).toMatchObject({ - tool_call_id: 'call_plan', - source: 'output', - plan: '# The Plan\n\nDo the thing.', - path: '/tmp/plans/foo.md', - }); - expect(body.data.plans[0]!.review).toBeUndefined(); - }); - - it('rebuilds plan info from the persisted interaction for a cold session (revise)', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await seedMainAgentMessages(id, [ - { role: 'user', content: [{ type: 'text', text: 'build it' }], toolCalls: [] }, - { - role: 'assistant', - content: [], - toolCalls: [{ type: 'function', id: 'call_plan', name: 'ExitPlanMode', arguments: '{}' }], - }, - { - role: 'tool', - content: [{ type: 'text', text: 'User requested revisions. Plan mode remains active.' }], - toolCalls: [], - toolCallId: 'call_plan', - }, - ]); - - interactions.enqueue({ - id: 'apr-plan', - kind: 'approval', - payload: { - toolCallId: 'call_plan', - toolName: 'ExitPlanMode', - action: 'Presenting plan and exiting plan mode', - display: { kind: 'plan_review', plan: '# Draft Plan', path: '/tmp/plans/foo.md' }, - }, - tags: { agentId: 'main', sessionId: id, turnId: 0 }, - }); - interactions.respond('apr-plan', { - decision: 'rejected', - selectedLabel: 'Revise', - feedback: 'split it up', - }); - const agent = getLiveSessionById(server!.core.accessor, id)! - .accessor.get(IAgentLifecycleService) - .handleOf('main'); - await agent!.accessor.get(IWireService).flush(); - - await server!.close(); - server = undefined; - await boot(); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript/plan?agent_id=main&tool_call_id=call_plan`, - ); - expect(body.code).toBe(0); - expect(body.data.plans).toHaveLength(1); - expect(body.data.plans[0]).toMatchObject({ - source: 'interaction', - plan: '# Draft Plan', - path: '/tmp/plans/foo.md', - review: { state: 'rejected', selected_option: 'Revise', feedback: 'split it up' }, - }); - }); - - it('answers 40401 / 40416 / 40001 on the plan route', async () => { - const missing = await getJson( - '/api/v1/sessions/nope/transcript/plan?agent_id=main&tool_call_id=call_plan', - ); - expect(missing.body.code).toBe(40401); - - const id = await createSession(); - await ensureMainAgent(id); - await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - - const bus = mainAgentBus(id); - bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 })); - bus.publish( - serverEvent({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_bash', name: 'Bash', args: {} }), - ); - bus.publish(serverEvent({ type: 'tool.result', turnId: 1, toolCallId: 'call_bash', output: 'ok' })); - - const unknown = await getJson( - `/api/v1/sessions/${id}/transcript/plan?agent_id=main&tool_call_id=call_nope`, - ); - expect(unknown.body.code).toBe(40416); - - const notPlan = await getJson( - `/api/v1/sessions/${id}/transcript/plan?agent_id=main&tool_call_id=call_bash`, - ); - expect(notPlan.body.code).toBe(40416); - - const hostile = await getJson( - `/api/v1/sessions/${id}/transcript/plan?agent_id=${encodeURIComponent('../main')}&tool_call_id=call_plan`, - ); - expect(hostile.body.code).toBe(40001); - }); - - it('lists every ExitPlanMode plan of the agent when tool_call_id is omitted', async () => { - const id = await createSession(); - await ensureMainAgent(id); - await getJson(`/api/v1/sessions/${id}/transcript?agent_id=main`); - - const bus = mainAgentBus(id); - bus.publish(serverEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); - bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 1 })); - bus.publish( - serverEvent({ - type: 'tool.call.started', - turnId: 1, - toolCallId: 'call_draft', - name: 'ExitPlanMode', - args: {}, - display: { kind: 'plan_review', plan: '# Draft' }, - }), - ); - bus.publish( - serverEvent({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_bash', name: 'Bash', args: {} }), - ); - bus.publish(serverEvent({ type: 'tool.result', turnId: 1, toolCallId: 'call_bash', output: 'ok' })); - bus.publish(serverEvent({ type: 'turn.step.completed', turnId: 1, step: 1 })); - bus.publish(serverEvent({ type: 'turn.step.started', turnId: 1, step: 2 })); - bus.publish( - serverEvent({ - type: 'tool.call.started', - turnId: 1, - toolCallId: 'call_final', - name: 'ExitPlanMode', - args: {}, - display: { kind: 'plan_review', plan: '# Final', path: '/tmp/plans/foo.md' }, - }), - ); - - interactions.enqueue({ - id: 'apr-final', - kind: 'approval', - payload: { - toolCallId: 'call_final', - toolName: 'ExitPlanMode', - action: 'Presenting plan and exiting plan mode', - display: { kind: 'plan_review', plan: '# Final', path: '/tmp/plans/foo.md' }, - }, - tags: { agentId: 'main', sessionId: id, turnId: 1 }, - }); - interactions.respond('apr-final', { decision: 'approved' }); - - const { body } = await getJson( - `/api/v1/sessions/${id}/transcript/plan?agent_id=main`, - ); - expect(body.code).toBe(0); - expect(body.data.agent_id).toBe('main'); - expect(body.data.plans.map((p) => [p.tool_call_id, p.plan])).toEqual([ - ['call_draft', '# Draft'], - ['call_final', '# Final'], - ]); - expect(body.data.plans[0]!.review).toBeUndefined(); - expect(body.data.plans[1]!.review).toMatchObject({ state: 'approved' }); - }); -}); diff --git a/packages/kap-server/test/transcriptContract.e2e.test.ts b/packages/kap-server/test/transcriptContract.e2e.test.ts deleted file mode 100644 index 02537dcca89..00000000000 --- a/packages/kap-server/test/transcriptContract.e2e.test.ts +++ /dev/null @@ -1,583 +0,0 @@ - -import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { WebSocket, type RawData } from 'ws'; -import { - IAgentLifecycleService, - IConfigService, - MAIN_AGENT_ID, - getLiveSessionById, - resumeSessionById, -} from '@moonshot-ai/agent-core-v2'; - -import { type RunningServer, startServer } from '../src/start'; -import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; -import { authHeaders, bearerToken } from './helpers/auth'; - - -function sseLines(...events: readonly string[]): string { - return events.map((event) => `data: ${event}\n\n`).join('') + 'data: [DONE]\n\n'; -} - -function sseText(text: string): string { - return sseLines( - JSON.stringify({ - id: 'chatcmpl-mock', - object: 'chat.completion.chunk', - created: 1, - model: 'mock', - choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: null }], - }), - JSON.stringify({ - id: 'chatcmpl-mock', - object: 'chat.completion.chunk', - created: 1, - model: 'mock', - choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], - usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 }, - }), - ); -} - -function sseToolCall(id: string, name: string, args: string): string { - return sseLines( - JSON.stringify({ - id: 'chatcmpl-mock', - object: 'chat.completion.chunk', - created: 1, - model: 'mock', - choices: [ - { - index: 0, - delta: { - role: 'assistant', - tool_calls: [{ index: 0, id, type: 'function', function: { name, arguments: args } }], - }, - finish_reason: null, - }, - ], - }), - JSON.stringify({ - id: 'chatcmpl-mock', - object: 'chat.completion.chunk', - created: 1, - model: 'mock', - choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], - usage: { prompt_tokens: 10, completion_tokens: 6, total_tokens: 16 }, - }), - ); -} - -interface LlmRoute { - readonly match: (body: string) => boolean; - readonly respond: () => string; - readonly delayMs?: number; -} - -interface MockLlm { - readonly port: number; - readonly hits: string[]; - readonly close: () => Promise; -} - -async function startMockLlm(routes: readonly LlmRoute[], fallback: () => string = () => sseText('ok')): Promise { - const hits: string[] = []; - const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => { - const chunks: Buffer[] = []; - req.on('data', (chunk: Buffer) => chunks.push(chunk)); - req.on('end', () => { - const body = Buffer.concat(chunks).toString('utf8'); - hits.push(body); - const route = routes.find((r) => r.match(body)); - const respond = route?.respond ?? fallback; - const send = (): void => { - res.writeHead(200, { 'content-type': 'text/event-stream' }); - res.end(respond()); - }; - if (route?.delayMs !== undefined) setTimeout(send, route.delayMs); - else send(); - }); - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - if (address === null || typeof address === 'object' === false) throw new Error('no llm port'); - return { - port: address.port, - hits, - close: () => new Promise((resolve) => server.close(() => resolve())), - }; -} - - -function configToml(llmPort: number): string { - return [ - 'default_model = "stub"', - '', - '[providers.stub]', - 'type = "openai"', - `base_url = "http://127.0.0.1:${String(llmPort)}"`, - 'api_key = "stub"', - '', - '[models.stub]', - 'provider = "stub"', - 'model = "stub"', - 'max_context_size = 100000', - '', - ].join('\n'); -} - -interface Envelope { - code: number; - msg: string; - data: T; -} - -async function rest(server: RunningServer, base: string, path: string, init?: { method?: string; body?: unknown }): Promise { - const res = await fetch(`${base}${path}`, { - method: init?.method ?? 'GET', - headers: authHeaders(server, init?.body !== undefined ? { 'content-type': 'application/json' } : {}), - body: init?.body !== undefined ? JSON.stringify(init.body) : undefined, - }); - const envelope = (await res.json()) as Envelope & { data: T }; - if (envelope.code !== 0) throw new Error(`REST ${path} failed: ${JSON.stringify(envelope).slice(0, 300)}`); - return envelope.data; -} - -interface TxSnapshot { - items: any[]; - tasks: any[]; - interactions: any[]; - attachments: any[]; - todos: any[]; - prompts: { promptId: string; status: string }[]; - meta: { activity?: string; agent?: unknown; goal?: unknown; modes?: unknown }; -} - -const getTranscript = (server: RunningServer, base: string, sid: string): Promise => - rest(server, base, `/api/v1/sessions/${encodeURIComponent(sid)}/transcript?agent_id=main`); - -const getSessionFacts = (server: RunningServer, base: string, sid: string): Promise<{ busy: boolean; pendingInteraction: string }> => - rest(server, base, `/api/v1/sessions/${encodeURIComponent(sid)}`); - -async function createSession(server: RunningServer, base: string): Promise { - const data = await rest<{ id: string }>(server, base, '/api/v1/sessions', { - method: 'POST', - body: { metadata: { cwd: '/tmp' } }, - }); - return data.id; -} - -function submitPrompt(server: RunningServer, base: string, sid: string, text: string, permissionMode: 'manual' | 'yolo' = 'yolo'): Promise<{ prompt_id: string }> { - return rest<{ prompt_id: string }>(server, base, `/api/v1/sessions/${encodeURIComponent(sid)}/prompts`, { - method: 'POST', - body: { content: [{ type: 'text', text }], model: 'stub', permission_mode: permissionMode }, - }); -} - -async function until(label: string, fn: () => Promise | boolean, timeoutMs = 30000, intervalMs = 150): Promise { - const start = Date.now(); - for (;;) { - if (await fn()) return; - if (Date.now() - start > timeoutMs) throw new Error(`timeout waiting for: ${label}`); - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } -} - -interface TranscriptChannel { - readonly frames: any[]; - readonly ops: any[]; - reset(): any; - close(): void; -} - -function rawToString(data: RawData): string { - if (typeof data === 'string') return data; - if (Buffer.isBuffer(data)) return data.toString('utf8'); - if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); - return Buffer.from(data as ArrayBuffer).toString('utf8'); -} - -async function subscribeTranscript(server: RunningServer, sid: string): Promise { - const ws = new WebSocket(`ws://127.0.0.1:${server.port}/api/v1/ws`, [`kimi-code.bearer.${bearerToken(server)}`]); - const frames: any[] = []; - const ops: any[] = []; - let resetFrame: any; - ws.on('message', (data) => { - let frame: any; - try { - frame = JSON.parse(rawToString(data)); - } catch { - return; - } - frames.push(frame); - const payload = frame.payload as { agent_id?: string; ops?: any[] } | undefined; - if (frame.type === 'transcript.reset' && payload?.agent_id === 'main' && resetFrame === undefined) { - resetFrame = frame; - } - if (frame.type === 'transcript.ops' && payload?.agent_id === 'main') ops.push(...(payload.ops ?? [])); - }); - await new Promise((resolve, reject) => { - ws.once('open', () => { - resolve(); - }); - ws.once('error', reject); - }); - ws.send(JSON.stringify({ type: 'subscribe_v2', id: 'sub-1', payload: { session_id: sid, transcript: { '*': 'delta' } } })); - await until('transcript.reset', () => resetFrame !== undefined, 15000); - return { - frames, - ops, - reset: () => resetFrame?.payload, - close: () => ws.close(), - }; -} - - -describe('transcript contract e2e', () => { - let home: string | undefined; - let server: RunningServer | undefined; - let llm: MockLlm | undefined; - let base: string; - - beforeAll(async () => { - home = await mkdtemp(join(tmpdir(), 'kimi-transcript-contract-')); - server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - base = `http://127.0.0.1:${server.port}`; - }); - - afterEach(async () => { - await llm?.close(); - llm = undefined; - }); - - afterAll(async () => { - await server?.close(); - server = undefined; - if (home !== undefined) await rm(home, { recursive: true, force: true }); - home = undefined; - }); - - async function boot(routes: readonly LlmRoute[]): Promise { - llm = await startMockLlm(routes); - await writeFile(join(home!, 'config.toml'), configToml(llm.port), 'utf-8'); - await server!.core.accessor.get(IConfigService).reload(); - } - - const idle = (server: RunningServer, base: string, sid: string) => - until('session idle', async () => !(await getSessionFacts(server, base, sid)).busy); - - function dumpState(tx: TxSnapshot, hits: string[]): string { - const turns = tx.items - .filter((i) => i.kind === 'turn') - .map((t: any) => ({ - id: t.turnId, - state: t.state, - origin: t.origin?.kind, - steps: t.steps.map((s: any) => ({ - ordinal: s.ordinal, - state: s.state, - frames: s.frames.map((f: any) => `${f.kind}:${f.role ?? ''}:${f.name ?? ''}:${f.state ?? ''}`), - })), - })); - const markers = hits - .map((hit) => /"content":"([^"]{0,60})/.exec(hit)?.[1] ?? hit.slice(0, 60)) - .slice(0, 8); - return `${JSON.stringify({ meta: tx.meta, prompts: tx.prompts, turns, interactions: tx.interactions })}\nllm hits (${hits.length}): ${JSON.stringify(markers)}`; - } - - const idleOrDump = async (server: RunningServer, base: string, sid: string): Promise => { - try { - await idle(server, base, sid); - } catch (error) { - const tx = await getTranscript(server, base, sid); - throw new Error(`${(error as Error).message}\ntranscript at timeout: ${dumpState(tx, llm?.hits ?? [])}`, { cause: error }); - } - }; - - it('S1: turn lifecycle produces activity, prompts and turn frames on both channels', async () => { - await boot([{ match: () => true, respond: () => sseText('hello world'), delayMs: 3000 }]); - const sid = await createSession(server!, base); - await submitPrompt(server!, base, sid, 'say hello'); - const channel = await subscribeTranscript(server!, sid); - - await until('turn running + prompt tracked', async () => { - const tx = await getTranscript(server!, base, sid); - return ( - tx.meta.activity === 'turn' && - tx.prompts.some((p) => p.status === 'running') && - tx.items.some((i) => i.kind === 'turn' && i.state === 'running') - ); - }); - const mid = await getTranscript(server!, base, sid); - expect(mid.meta.activity).toBe('turn'); - expect(mid.prompts.length).toBeGreaterThan(0); - expect(mid.prompts[0]!.promptId.length).toBeGreaterThan(0); - - await idle(server!, base, sid); - - const end = await getTranscript(server!, base, sid); - expect(end.meta.activity).toBe('idle'); - const turn = end.items.find((i) => i.kind === 'turn'); - expect(turn).toMatchObject({ state: 'completed' }); - expect(typeof turn.endedAt).toBe('string'); - const frameKinds = turn.steps.flatMap((s: any) => s.frames).map((f: any) => f.kind); - expect(frameKinds).toContain('text'); - const promptStatuses = end.prompts.map((p) => p.status); - expect(promptStatuses.every((s) => s === 'completed')).toBe(true); - - const reset = channel.reset(); - expect(reset.snapshot.meta.activity).toBe('turn'); - const opTypes = new Set(channel.ops.map((o: any) => o.op)); - expect(opTypes.has('turn.upsert')).toBe(true); - expect(opTypes.has('step.upsert')).toBe(true); - expect(opTypes.has('frame.upsert') || opTypes.has('append')).toBe(true); - expect(opTypes.has('prompt.upsert')).toBe(true); - const activityMerges = channel.ops.filter((o: any) => o.op === 'meta.merge' && o.meta?.activity !== undefined); - expect(activityMerges.map((o: any) => o.meta.activity)).toContain('idle'); - channel.close(); - }); - - it('S2: a prompt submitted mid-turn is tracked as queued through settlement', async () => { - await boot([ - { match: (body) => body.includes('first prompt'), respond: () => sseText('first done'), delayMs: 2500 }, - { match: () => true, respond: () => sseText('second done') }, - ]); - const sid = await createSession(server!, base); - await submitPrompt(server!, base, sid, 'first prompt'); - await until('first prompt running', async () => - (await getTranscript(server!, base, sid)).prompts.some((p) => p.status === 'running'), - ); - - await submitPrompt(server!, base, sid, 'second prompt'); - await until('second prompt queued', async () => { - const tx = await getTranscript(server!, base, sid); - return tx.prompts.some((p) => p.status === 'queued') && tx.prompts.some((p) => p.status === 'running'); - }); - const mid = await getTranscript(server!, base, sid); - expect(mid.prompts.map((p) => p.status).sort()).toEqual(['queued', 'running']); - - await until('both settled', async () => { - const tx = await getTranscript(server!, base, sid); - return tx.prompts.length > 0 && tx.prompts.every((p) => p.status === 'completed'); - }, 45000); - }); - - it('S3: a pending approval appears as an interaction with tool linkage, then resolves', async () => { - await boot([ - { match: (body) => !body.includes('echo contract-hi'), respond: () => sseToolCall('call_1', 'Bash', '{"command":"echo contract-hi"}') }, - { match: () => true, respond: () => sseText('tool done') }, - ]); - const sid = await createSession(server!, base); - await submitPrompt(server!, base, sid, 'run the echo', 'manual'); - - await until('approval pending', async () => { - const tx = await getTranscript(server!, base, sid); - return tx.interactions.some((x: any) => x.interactionKind === 'approval' && x.state === 'pending'); - }); - const mid = await getTranscript(server!, base, sid); - const approval = mid.interactions.find((x: any) => x.interactionKind === 'approval' && x.state === 'pending'); - expect(approval).toBeDefined(); - expect(approval.toolCallId).toBe('call_1'); - expect((approval.request as any)?.toolName).toBe('Bash'); - expect(mid.meta.agent).toBeDefined(); - - await rest(server!, base, `/api/v1/sessions/${encodeURIComponent(sid)}/approvals/${encodeURIComponent(approval.interactionId)}`, { - method: 'POST', - body: { decision: 'approved' }, - }); - await idle(server!, base, sid); - - const end = await getTranscript(server!, base, sid); - expect(end.interactions.every((x: any) => x.state !== 'pending')).toBe(true); - expect(end.meta.activity).toBe('idle'); - const toolFrame = end.items - .filter((i) => i.kind === 'turn') - .flatMap((t: any) => t.steps) - .flatMap((s: any) => s.frames) - .find((f: any) => f.kind === 'tool' && f.name === 'Bash'); - expect(toolFrame).toMatchObject({ state: 'done' }); - expect(String(toolFrame.output)).toContain('contract-hi'); - }); - - it('S4: a background subagent produces task entities and a task-origin notification turn', async () => { - await boot([ - { - match: (body) => body.includes('spawn-bg') && !body.includes('"role":"tool"'), - respond: () => sseToolCall('call_a', 'Agent', '{"prompt":"bg-answer-42","description":"bg ans","run_in_background":true}'), - }, - { match: (body) => body.includes('bg-answer-42'), respond: () => sseText('42'), delayMs: 2500 }, - { match: () => true, respond: () => sseText('noted') }, - ]); - const sid = await createSession(server!, base); - await submitPrompt(server!, base, sid, 'spawn-bg one background agent'); - - await until('background task running', async () => { - const tx = await getTranscript(server!, base, sid); - return tx.tasks.some((t: any) => t.kind === 'subagent' && t.state === 'running'); - }); - const mid = await getTranscript(server!, base, sid); - const task = mid.tasks.find((t: any) => t.kind === 'subagent'); - expect(task).toMatchObject({ state: 'running', detached: true }); - expect(typeof task.agentId).toBe('string'); - - await until('task completed', async () => { - const tx = await getTranscript(server!, base, sid); - return tx.tasks.some((t: any) => t.taskId === task.taskId && t.state === 'completed'); - }, 45000).catch(async (error) => { - const tx = await getTranscript(server!, base, sid); - const opsData = await rest<{ batches: { seq: number; ops: any[] }[] }>( - server!, - base, - `/api/v1/sessions/${encodeURIComponent(sid)}/transcript/ops?agent_id=main&since_seq=0`, - ); - const taskOps = opsData.batches.flatMap((b) => - b.ops - .filter((o: any) => o.op === 'task.upsert') - .map((o: any) => `${b.seq}:${o.task.taskId}:${o.task.state}`), - ); - throw new Error(`${(error as Error).message}\ntasks: ${JSON.stringify(tx.tasks)}\ntaskOps: ${JSON.stringify(taskOps)}`, { cause: error }); - }); - await until('notification turn exists', async () => { - const tx = await getTranscript(server!, base, sid); - return tx.items.some((i: any) => i.kind === 'turn' && i.origin?.kind === 'task'); - }, 45000); - - const end = await getTranscript(server!, base, sid); - const taskTurn = end.items.find((i: any) => i.kind === 'turn' && i.origin?.kind === 'task'); - expect(taskTurn).toBeDefined(); - expect(JSON.stringify(taskTurn)).toContain('notification'); - await idleOrDump(server!, base, sid); - expect((await getTranscript(server!, base, sid)).meta.activity).toBe('idle'); - }); - - it('S5: late attach backfills liveness and the prompt queue from the live loop', async () => { - await boot([{ match: () => true, respond: () => sseText('slow answer'), delayMs: 4000 }]); - const sid = await createSession(server!, base); - await submitPrompt(server!, base, sid, 'take your time'); - - const tx = await getTranscript(server!, base, sid); - expect(tx.meta.activity).toBe('turn'); - expect(tx.items.some((i: any) => i.kind === 'turn' && i.state === 'running')).toBe(true); - expect(tx.prompts.some((p) => p.status === 'running')).toBe(true); - - const channel = await subscribeTranscript(server!, sid); - expect(channel.reset().snapshot.meta.activity).toBe('turn'); - await idle(server!, base, sid); - expect((await getTranscript(server!, base, sid)).meta.activity).toBe('idle'); - channel.close(); - }); - - it('S6: REST snapshot and WS reset agree on every global entity', async () => { - await boot([ - { match: (body) => !body.includes('echo s6'), respond: () => sseToolCall('call_s6', 'Bash', '{"command":"echo s6"}') }, - { match: () => true, respond: () => sseText('s6 done') }, - ]); - const sid = await createSession(server!, base); - await submitPrompt(server!, base, sid, 'run echo for s6'); - await idle(server!, base, sid); - - const snapshot = await getTranscript(server!, base, sid); - const channel = await subscribeTranscript(server!, sid); - const reset = channel.reset().snapshot; - - expect(reset.meta).toEqual(snapshot.meta); - const byId = (xs: any[], key: string): Record => - Object.fromEntries(xs.map((x) => [x[key], x])); - expect(Object.keys(byId(reset.tasks ?? [], 'taskId')).sort()).toEqual( - Object.keys(byId(snapshot.tasks, 'taskId')).sort(), - ); - expect(Object.keys(byId(reset.interactions ?? [], 'interactionId')).sort()).toEqual( - Object.keys(byId(snapshot.interactions, 'interactionId')).sort(), - ); - expect(Object.keys(byId(reset.prompts ?? [], 'promptId')).sort()).toEqual( - Object.keys(byId(snapshot.prompts, 'promptId')).sort(), - ); - expect(Object.keys(byId(reset.todos ?? [], 'todoId')).sort()).toEqual( - Object.keys(byId(snapshot.todos, 'todoId')).sort(), - ); - channel.close(); - }); - - it('S7: a foreground subagent resumes with its prior context after a server restart', async () => { - let childAgentId: string | undefined; - let resumedChildRequest: string | undefined; - await boot([ - { - match: (body) => body.includes('spawn-child') && !body.includes('"role":"tool"'), - respond: () => - sseToolCall( - 'call_spawn', - 'Agent', - JSON.stringify({ prompt: 'remember the token quartz-7731 and reply with ok', description: 'child' }), - ), - }, - { - match: (body) => - body.includes('remember the token') && !body.includes('spawn-child') && !body.includes('recall the token'), - respond: () => sseText('ok, remembered'), - }, - { - match: (body) => body.includes('resume-child') && !body.includes('recall the token'), - respond: () => - sseToolCall( - 'call_resume', - 'Agent', - JSON.stringify({ prompt: 'recall the token', description: 'child again', resume: childAgentId }), - ), - }, - { - match: (body) => { - const hit = body.includes('recall the token') && !body.includes('resume-child'); - if (hit) resumedChildRequest = body; - return hit; - }, - respond: () => sseText('the token is quartz-7731'), - }, - { match: () => true, respond: () => sseText('noted') }, - ]); - const sid = await createSession(server!, base); - await submitPrompt(server!, base, sid, 'spawn-child now'); - await idle(server!, base, sid); - - const liveBefore = getLiveSessionById(server!.core.accessor, sid); - expect(liveBefore).toBeDefined(); - const childIds = liveBefore!.accessor - .get(IAgentLifecycleService) - .list() - .map((agent) => agent.agentId) - .filter((id) => id !== MAIN_AGENT_ID); - expect(childIds).toHaveLength(1); - childAgentId = childIds[0]; - - await server!.close(); - server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home!, logLevel: 'silent' }); - base = `http://127.0.0.1:${server.port}`; - - const resumed = await resumeSessionById(server.core.accessor, sid); - expect(resumed).toBeDefined(); - const agents = resumed!.accessor.get(IAgentLifecycleService); - expect(agents.handleOf(childAgentId!)).toBeUndefined(); - - await submitPrompt(server, base, sid, 'resume-child now'); - await idle(server, base, sid); - - expect(resumedChildRequest).toBeDefined(); - expect(resumedChildRequest).toContain('quartz-7731'); - expect(resumedChildRequest).toContain('ok, remembered'); - expect(agents.handleOf(childAgentId!)).toBeDefined(); - - const end = await getTranscript(server, base, sid); - const agentFrames = end.items - .filter((i) => i.kind === 'turn') - .flatMap((t: any) => t.steps) - .flatMap((s: any) => s.frames) - .filter((f: any) => f.kind === 'tool' && f.name === 'Agent'); - expect(agentFrames).toHaveLength(2); - expect(String(agentFrames[1].output)).toContain(`agent_id: ${childAgentId}`); - expect(String(agentFrames[1].output)).toContain('the token is quartz-7731'); - }, 60000); -}, 90000); diff --git a/packages/kap-server/test/workspaceLayout.test.ts b/packages/kap-server/test/workspaceLayout.test.ts index 4304b5246fa..9db63ce8fdc 100644 --- a/packages/kap-server/test/workspaceLayout.test.ts +++ b/packages/kap-server/test/workspaceLayout.test.ts @@ -60,7 +60,7 @@ describe('local/local on-disk layout (byte compatibility)', () => { return (await res.json()) as Envelope; } - it('persists the pre-refactor layout byte-for-byte and serves it through the snapshot reader', async () => { + it('persists the pre-refactor layout byte-for-byte and serves it through the history reader', async () => { const created = await postJson<{ id: string; workspace_id: string }>('/api/v1/sessions', { metadata: { cwd: workDir }, }); @@ -105,12 +105,12 @@ describe('local/local on-disk layout (byte compatibility)', () => { }; expect(metaWithAgent.agents['main']?.homedir).toBe(join(sessionDir, 'agents', 'main')); - const snapshot = await fetch(`${base}/api/v1/sessions/${sessionId}/snapshot`, { + const history = await fetch(`${base}/api/v1/sessions/${sessionId}/history`, { headers: authHeaders(server!), }); - const snapshotBody = (await snapshot.json()) as Envelope<{ session: { id: string } }>; - expect(snapshotBody.code).toBe(0); - expect(snapshotBody.data.session.id).toBe(sessionId); + const historyBody = (await history.json()) as Envelope<{ messages: unknown[] }>; + expect(historyBody.code).toBe(0); + expect(historyBody.data.messages).toEqual([]); const second = await postJson<{ id: string; workspace_id: string }>('/api/v1/sessions', { metadata: { cwd: workDir }, diff --git a/packages/kap-server/test/wsBearerProtocol.test.ts b/packages/kap-server/test/wsBearerProtocol.test.ts index 83bbcaf0043..a024185293d 100644 --- a/packages/kap-server/test/wsBearerProtocol.test.ts +++ b/packages/kap-server/test/wsBearerProtocol.test.ts @@ -23,14 +23,14 @@ describe('server-v2 WS bearer subprotocol', () => { it('accepts a valid bearer subprotocol', async () => { const token = sharedServer().token; - const wsUrl = `${sharedServer().base.replace(/^http/, 'ws')}/api/v1/ws`; + const wsUrl = `${sharedServer().base.replace(/^http/, 'ws')}/api/v3/ws`; const ws = await openWs(wsUrl, `${WS_BEARER_PROTOCOL_PREFIX}${token}`); sockets.push(ws); expect(ws.protocol).toBe(`${WS_BEARER_PROTOCOL_PREFIX}${token}`); }); it('rejects an invalid bearer subprotocol', async () => { - const wsUrl = `${sharedServer().base.replace(/^http/, 'ws')}/api/v1/ws`; + const wsUrl = `${sharedServer().base.replace(/^http/, 'ws')}/api/v3/ws`; await expect(openWs(wsUrl, `${WS_BEARER_PROTOCOL_PREFIX}wrong-token`)).rejects.toThrow(); }); }); diff --git a/packages/kap-server/test/wsConnectionV1.test.ts b/packages/kap-server/test/wsConnectionV1.test.ts deleted file mode 100644 index 8d29a50dd1c..00000000000 --- a/packages/kap-server/test/wsConnectionV1.test.ts +++ /dev/null @@ -1,902 +0,0 @@ -import type { WebSocket } from 'ws'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { IConnectionRegistry } from '../src/transport/ws/connectionRegistry'; -import type { SessionEventBroadcaster } from '../src/transport/ws/v1/sessionEventBroadcaster'; -import { - type WsConnectionV1Options, - WsConnectionV1, - coalesceFrames, -} from '../src/transport/ws/v1/wsConnectionV1'; - -class FakeSocket { - readonly OPEN = 1; - readonly CLOSED = 3; - readyState = 1; - bufferedAmount = 0; - sent: string[] = []; - closeCalls: Array<{ code?: number; reason?: string }> = []; - private readonly handlers = new Map void>>(); - - on(event: string, cb: (...a: unknown[]) => void): this { - const list = this.handlers.get(event) ?? []; - list.push(cb); - this.handlers.set(event, list); - return this; - } - - send(data: string): void { - this.sent.push(data); - } - - close(code?: number, reason?: string): void { - this.closeCalls.push({ code, reason }); - this.readyState = this.CLOSED; - this.emit('close'); - } - - terminate(): void { - this.readyState = this.CLOSED; - this.emit('close'); - } - - emit(event: string, ...a: unknown[]): void { - for (const cb of this.handlers.get(event) ?? []) cb(...a); - } - - frames(): unknown[] { - return this.sent.map((s) => JSON.parse(s)); - } -} - -function makeBroadcaster(): SessionEventBroadcaster { - return { - subscribe: async () => true, - unsubscribe: () => {}, - addGlobalTarget: () => {}, - removeGlobalTarget: () => {}, - getCursor: async () => ({ seq: 0, epoch: '' }), - getBufferedSince: async () => ({ - events: [], - resyncRequired: false, - currentSeq: 0, - epoch: '', - }), - } as unknown as SessionEventBroadcaster; -} - -function makeRegistry(): IConnectionRegistry { - return { - add: () => {}, - remove: () => {}, - get: () => undefined, - values: () => [], - closeAll: () => {}, - size: () => 0, - }; -} - -function makeConn(socket: FakeSocket, opts: Partial = {}): WsConnectionV1 { - return new WsConnectionV1({ - socket: socket as unknown as WebSocket, - broadcaster: makeBroadcaster(), - connectionRegistry: makeRegistry(), - remoteAddress: null, - userAgent: null, - ...opts, - }); -} - -function delta( - sessionId: string, - agentId: string, - turnId: number, - text: string, - offset: number, - type: 'assistant.delta' | 'thinking.delta' = 'assistant.delta', -) { - return { - type, - seq: 1, - volatile: true as const, - offset, - session_id: sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - payload: { type, agentId, sessionId, turnId, delta: text }, - }; -} - -function durable(type: string, sessionId: string, seq: number) { - return { - type, - seq, - session_id: sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - payload: { type, agentId: 'main', sessionId }, - }; -} - -describe('coalesceFrames', () => { - it('merges adjacent compatible assistant deltas', () => { - const out = coalesceFrames([ - delta('s1', 'main', 1, 'Hello', 0), - delta('s1', 'main', 1, ' ', 5), - delta('s1', 'main', 1, 'world', 6), - ]); - expect(out).toHaveLength(1); - const f = out[0] as { offset: number; volatile: boolean; seq: number; payload: { delta: string } }; - expect(f.payload.delta).toBe('Hello world'); - expect(f.offset).toBe(0); - expect(f.volatile).toBe(true); - expect(f.seq).toBe(1); - }); - - it('does not merge across a durable frame', () => { - const out = coalesceFrames([ - delta('s1', 'main', 1, 'a', 0), - durable('turn.ended', 's1', 2), - delta('s1', 'main', 1, 'b', 1), - ]); - expect(out).toHaveLength(3); - expect((out[0] as { payload: { delta: string } }).payload.delta).toBe('a'); - expect((out[1] as { type: string }).type).toBe('turn.ended'); - expect((out[2] as { payload: { delta: string } }).payload.delta).toBe('b'); - }); - - it('does not merge different delta types', () => { - const out = coalesceFrames([ - delta('s1', 'main', 1, 'hi', 0, 'assistant.delta'), - delta('s1', 'main', 1, 'think', 0, 'thinking.delta'), - ]); - expect(out).toHaveLength(2); - }); - - it('does not merge deltas from different sessions / agents / turns', () => { - expect( - coalesceFrames([delta('s1', 'main', 1, 'a', 0), delta('s2', 'main', 1, 'b', 0)]), - ).toHaveLength(2); - expect( - coalesceFrames([delta('s1', 'main', 1, 'a', 0), delta('s1', 'sub', 1, 'b', 0)]), - ).toHaveLength(2); - expect( - coalesceFrames([delta('s1', 'main', 1, 'a', 0), delta('s1', 'main', 2, 'b', 0)]), - ).toHaveLength(2); - }); - - it('leaves non-volatile and non-text frames untouched', () => { - const toolCallDelta = { - type: 'tool.call.delta', - seq: 1, - volatile: true as const, - session_id: 's1', - timestamp: '2026-01-01T00:00:00.000Z', - payload: { type: 'tool.call.delta', agentId: 'main', turnId: 1, args: { x: 1 } }, - }; - expect(coalesceFrames([toolCallDelta, toolCallDelta])).toHaveLength(2); - }); - - it('does not mutate the input frames', () => { - const a = delta('s1', 'main', 1, 'a', 0); - const b = delta('s1', 'main', 1, 'b', 1); - const out = coalesceFrames([a, b]); - expect(out).toHaveLength(1); - expect(a.payload.delta).toBe('a'); - expect(b.payload.delta).toBe('b'); - }); - - it('handles empty and single-element input', () => { - expect(coalesceFrames([])).toEqual([]); - const only = delta('s1', 'main', 1, 'x', 0); - const out = coalesceFrames([only]); - expect(out).toHaveLength(1); - expect(out[0]).toBe(only); - }); -}); - -describe('WsConnectionV1 transcript subscriptions (subscribe_v2)', () => { - interface SubscribeCall { - sessionId: string; - filter: unknown; - grades: unknown; - opts?: { deferTranscriptReset?: boolean; transcriptSince?: Record }; - } - - function makeCapturingBroadcaster(): { - broadcaster: SessionEventBroadcaster; - calls: SubscribeCall[]; - detaches: { sessionId: string; agentIds?: readonly string[] }[]; - } { - const calls: SubscribeCall[] = []; - const detaches: { sessionId: string; agentIds?: readonly string[] }[] = []; - const broadcaster = { - subscribe: async ( - sessionId: string, - _target: unknown, - filter: unknown, - grades: unknown, - opts?: { deferTranscriptReset?: boolean; transcriptSince?: Record }, - ) => { - calls.push({ sessionId, filter, grades, opts }); - return true; - }, - unsubscribe: () => {}, - unsubscribeTranscript: (sessionId: string, _target: unknown, agentIds?: readonly string[]) => { - detaches.push({ sessionId, agentIds }); - }, - addGlobalTarget: () => {}, - removeGlobalTarget: () => {}, - getCursor: async () => ({ seq: 0, epoch: '' }), - getBufferedSince: async () => ({ - events: [], - resyncRequired: false, - currentSeq: 0, - epoch: '', - }), - } as unknown as SessionEventBroadcaster; - return { broadcaster, calls, detaches }; - } - - function controlFrame(type: string, payload: Record): string { - return JSON.stringify({ type, id: 'req-1', payload }); - } - - it('forwards subscribe_v2 grades and transcript_since to the broadcaster and stores them per session', async () => { - const socket = new FakeSocket(); - const { broadcaster, calls } = makeCapturingBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - - socket.emit( - 'message', - controlFrame('subscribe_v2', { - session_id: 's1', - transcript: { '*': 'delta' }, - transcript_since: { main: 7, '*': 3 }, - }), - ); - await vi.waitFor(() => expect(calls).toHaveLength(1)); - - expect(calls[0]).toMatchObject({ - sessionId: 's1', - grades: { '*': 'delta' }, - opts: { transcriptSince: { main: 7, '*': 3 } }, - }); - expect(conn.subscriptions.get('s1')).toEqual({ - agentFilter: undefined, - transcriptGrades: { '*': 'delta' }, - }); - await vi.waitFor(() => - expect(socket.sent.some((f) => JSON.parse(f).type === 'ack')).toBe(true), - ); - const ack = socket.sent.map((f) => JSON.parse(f)).find((f) => f.type === 'ack'); - expect(ack).toMatchObject({ code: 0, payload: { accepted: ['s1'], not_found: [] } }); - conn.close(); - }); - - it('ignores legacy transcript fields on client_hello and subscribe', async () => { - const socket = new FakeSocket(); - const { broadcaster, calls } = makeCapturingBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - - socket.emit( - 'message', - controlFrame('client_hello', { - client_id: 'c1', - subscriptions: ['s1'], - transcript: { s1: { '*': 'delta' } }, - transcript_since: { s1: { main: 7 } }, - }), - ); - await vi.waitFor(() => expect(calls).toHaveLength(1)); - expect(calls[0]).toMatchObject({ sessionId: 's1', grades: undefined }); - expect(calls[0]!.opts?.transcriptSince).toBeUndefined(); - expect(conn.subscriptions.get('s1')).toEqual({ - agentFilter: undefined, - transcriptGrades: undefined, - }); - - socket.emit( - 'message', - controlFrame('subscribe', { - session_ids: ['s2'], - transcript: { s2: { '*': 'delta' } }, - }), - ); - await vi.waitFor(() => expect(calls).toHaveLength(2)); - expect(calls[1]).toMatchObject({ sessionId: 's2', grades: undefined }); - expect(conn.subscriptions.get('s2')).toEqual({ - agentFilter: undefined, - transcriptGrades: undefined, - }); - conn.close(); - }); - - it('acks an invalid subscribe_v2 payload with an error and does not attach', async () => { - const socket = new FakeSocket(); - const { broadcaster, calls } = makeCapturingBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - - socket.emit( - 'message', - controlFrame('subscribe_v2', { - session_id: 's1', - transcript: { main: 'everything' }, - }), - ); - await vi.waitFor(() => - expect(socket.sent.some((f) => JSON.parse(f).type === 'ack')).toBe(true), - ); - - expect(calls).toHaveLength(0); - expect(conn.subscriptions.size).toBe(0); - const ack = socket.sent.map((f) => JSON.parse(f)).find((f) => f.type === 'ack'); - expect(ack.code).toBe(1); - conn.close(); - }); - - it('preserves the existing agent filter when subscribe_v2 updates the grades', async () => { - const socket = new FakeSocket(); - const { broadcaster, calls } = makeCapturingBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - - socket.emit( - 'message', - controlFrame('subscribe', { session_ids: ['s1'], agent_filter: { s1: ['main'] } }), - ); - await vi.waitFor(() => expect(calls).toHaveLength(1)); - - socket.emit( - 'message', - controlFrame('subscribe_v2', { session_id: 's1', transcript: { main: 'block' } }), - ); - await vi.waitFor(() => expect(calls).toHaveLength(2)); - - expect(calls[1]).toMatchObject({ sessionId: 's1', grades: { main: 'block' } }); - expect(calls[1]!.filter).toEqual(new Set(['main'])); - expect(conn.subscriptions.get('s1')).toEqual({ - agentFilter: new Set(['main']), - transcriptGrades: { main: 'block' }, - }); - conn.close(); - }); - - it('keeps subscribe_v2 grades across a plain re-subscribe and filters the cursor replay through them', async () => { - const socket = new FakeSocket(); - const backlog = [ - durable('turn.started', 's1', 3), - durable('assistant.delta', 's1', 4), - durable('event.session.work_changed', 's1', 5), - ]; - const PROJECTED = new Set(['turn.started', 'assistant.delta']); - let seenGrades: unknown; - const broadcaster = { - subscribe: async ( - _sid: string, - target: { send: (e: unknown) => void }, - _filter: unknown, - _grades: unknown, - opts?: { deferTranscriptReset?: boolean }, - ) => { - if (opts?.deferTranscriptReset !== true) { - target.send({ type: 'transcript.reset', seq: 10, session_id: 's1', payload: {} }); - } - return true; - }, - flushTranscriptSeed: async (_sid: string, target: { send: (e: unknown) => void }) => { - target.send({ type: 'transcript.reset', seq: 10, session_id: 's1', payload: {} }); - }, - unsubscribe: () => {}, - addGlobalTarget: () => {}, - removeGlobalTarget: () => {}, - getCursor: async () => ({ seq: 10, epoch: 'e1' }), - getBufferedSince: async (_sid: string, _cursor: unknown, _filter: unknown, grades: unknown) => { - seenGrades = grades; - return { - events: backlog - .filter((envelope) => grades === undefined || !PROJECTED.has(envelope.type)) - .map((envelope) => ({ seq: envelope.seq, envelope })), - resyncRequired: false, - currentSeq: 10, - epoch: 'e1', - }; - }, - } as unknown as SessionEventBroadcaster; - const conn = makeConn(socket, { broadcaster, flushIntervalMs: 1 }); - - socket.emit( - 'message', - controlFrame('subscribe_v2', { session_id: 's1', transcript: { '*': 'delta' } }), - ); - await vi.waitFor(() => { - const types = socket.frames().map((f) => (f as { type: string }).type); - expect(types).toContain('transcript.reset'); - }); - expect(conn.subscriptions.get('s1')?.transcriptGrades).toEqual({ '*': 'delta' }); - - socket.emit( - 'message', - controlFrame('subscribe', { - session_ids: ['s1'], - cursors: { s1: { seq: 2, epoch: 'e1' } }, - }), - ); - await vi.waitFor(() => expect(seenGrades).toEqual({ '*': 'delta' })); - expect(conn.subscriptions.get('s1')?.transcriptGrades).toEqual({ '*': 'delta' }); - - const types = socket.frames().map((f) => (f as { type: string }).type); - expect(types).not.toContain('turn.started'); - expect(types).not.toContain('assistant.delta'); - expect( - types.slice(types.indexOf('event.session.work_changed'), types.lastIndexOf('transcript.reset') + 1), - ).toEqual(['event.session.work_changed', 'transcript.reset']); - conn.close(); - }); - - it('reports an unknown session in the subscribe_v2 ack not_found list', async () => { - const socket = new FakeSocket(); - const { broadcaster } = makeCapturingBroadcaster(); - broadcaster.subscribe = async () => false; - const conn = makeConn(socket, { broadcaster }); - - socket.emit( - 'message', - controlFrame('subscribe_v2', { session_id: 'gone', transcript: { '*': 'delta' } }), - ); - await vi.waitFor(() => - expect(socket.sent.some((f) => JSON.parse(f).type === 'ack')).toBe(true), - ); - - const ack = socket.sent.map((f) => JSON.parse(f)).find((f) => f.type === 'ack'); - expect(ack).toMatchObject({ code: 0, payload: { accepted: [], not_found: ['gone'] } }); - expect(conn.subscriptions.size).toBe(0); - conn.close(); - }); - - it('unsubscribe_v2 detaches listed agents with an explicit off, keeping the filter and other grades', async () => { - const socket = new FakeSocket(); - const { broadcaster, calls, detaches } = makeCapturingBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - - socket.emit( - 'message', - controlFrame('subscribe', { session_ids: ['s1'], agent_filter: { s1: ['main'] } }), - ); - await vi.waitFor(() => expect(calls).toHaveLength(1)); - socket.emit( - 'message', - controlFrame('subscribe_v2', { session_id: 's1', transcript: { '*': 'delta' } }), - ); - await vi.waitFor(() => - expect(conn.subscriptions.get('s1')?.transcriptGrades).toEqual({ '*': 'delta' }), - ); - - socket.emit( - 'message', - controlFrame('unsubscribe_v2', { session_id: 's1', agent_ids: ['main'] }), - ); - await vi.waitFor(() => expect(detaches).toHaveLength(1)); - - expect(detaches[0]).toEqual({ sessionId: 's1', agentIds: ['main'] }); - expect(conn.subscriptions.get('s1')).toEqual({ - agentFilter: new Set(['main']), - transcriptGrades: { '*': 'delta', main: 'off' }, - }); - const ack = socket.sent.map((f) => JSON.parse(f)).findLast((f) => f.type === 'ack'); - expect(ack).toMatchObject({ code: 0, payload: { accepted: ['s1'], not_found: [] } }); - conn.close(); - }); - - it('unsubscribe_v2 without agent_ids detaches the whole transcript stream', async () => { - const socket = new FakeSocket(); - const { broadcaster, detaches } = makeCapturingBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - - socket.emit( - 'message', - controlFrame('subscribe_v2', { session_id: 's1', transcript: { '*': 'delta' } }), - ); - await vi.waitFor(() => - expect(conn.subscriptions.get('s1')?.transcriptGrades).toEqual({ '*': 'delta' }), - ); - - socket.emit('message', controlFrame('unsubscribe_v2', { session_id: 's1' })); - await vi.waitFor(() => expect(detaches).toHaveLength(1)); - - expect(detaches[0]).toEqual({ sessionId: 's1', agentIds: undefined }); - expect(conn.subscriptions.get('s1')).toEqual({ - agentFilter: undefined, - transcriptGrades: undefined, - }); - conn.close(); - }); - - it('unsubscribe_v2 is idempotent for an unsubscribed session and never touches the broadcaster', async () => { - const socket = new FakeSocket(); - const { broadcaster, calls, detaches } = makeCapturingBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - - socket.emit('message', controlFrame('unsubscribe_v2', { session_id: 's1' })); - await vi.waitFor(() => - expect(socket.sent.some((f) => JSON.parse(f).type === 'ack')).toBe(true), - ); - - expect(calls).toHaveLength(0); - expect(detaches).toHaveLength(0); - const ack = socket.sent.map((f) => JSON.parse(f)).find((f) => f.type === 'ack'); - expect(ack).toMatchObject({ code: 0, payload: { accepted: ['s1'] } }); - conn.close(); - }); - - it('acks an invalid unsubscribe_v2 payload with an error', async () => { - const socket = new FakeSocket(); - const { broadcaster, detaches } = makeCapturingBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - - socket.emit('message', controlFrame('unsubscribe_v2', { agent_ids: ['main'] })); - socket.emit( - 'message', - controlFrame('unsubscribe_v2', { session_id: 's1', agent_ids: [] }), - ); - await vi.waitFor(() => - expect(socket.sent.filter((f) => JSON.parse(f).type === 'ack')).toHaveLength(2), - ); - - expect(detaches).toHaveLength(0); - const acks = socket.sent.map((f) => JSON.parse(f)).filter((f) => f.type === 'ack'); - expect(acks.every((a) => a.code === 1)).toBe(true); - conn.close(); - }); - - it('serializes back-to-back control frames: subscribe then subscribe_v2 lands filter and grades', async () => { - const socket = new FakeSocket(); - const { broadcaster, calls } = makeCapturingBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - - socket.emit( - 'message', - controlFrame('subscribe', { session_ids: ['s1'], agent_filter: { s1: ['main'] } }), - ); - socket.emit( - 'message', - controlFrame('subscribe_v2', { session_id: 's1', transcript: { '*': 'delta' } }), - ); - await vi.waitFor(() => expect(calls).toHaveLength(2)); - - expect(conn.subscriptions.get('s1')).toEqual({ - agentFilter: new Set(['main']), - transcriptGrades: { '*': 'delta' }, - }); - conn.close(); - }); - - it('re-subscribes an agent at full grade after it was detached', async () => { - const socket = new FakeSocket(); - const { broadcaster, calls } = makeCapturingBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - - socket.emit( - 'message', - controlFrame('subscribe_v2', { session_id: 's1', transcript: { '*': 'delta' } }), - ); - socket.emit('message', controlFrame('unsubscribe_v2', { session_id: 's1' })); - await vi.waitFor(() => - expect(conn.subscriptions.get('s1')?.transcriptGrades).toBeUndefined(), - ); - - socket.emit( - 'message', - controlFrame('subscribe_v2', { session_id: 's1', transcript: { main: 'turn' } }), - ); - await vi.waitFor(() => expect(calls).toHaveLength(2)); - - expect(calls[1]).toMatchObject({ sessionId: 's1', grades: { main: 'turn' } }); - expect(conn.subscriptions.get('s1')?.transcriptGrades).toEqual({ main: 'turn' }); - conn.close(); - }); -}); - -describe('WsConnectionV1 outbound buffer', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - it('sends server_hello immediately', () => { - const socket = new FakeSocket(); - const conn = makeConn(socket, { flushIntervalMs: 16 }); - expect(socket.frames().map((f) => (f as { type: string }).type)).toEqual(['server_hello']); - conn.close(); - }); - - it('buffers subscribe_v2 transcript frames without merging them', async () => { - const socket = new FakeSocket(); - const conn = makeConn(socket, { flushIntervalMs: 16 }); - socket.sent = []; - - conn.send(durable('transcript.reset', 's1', 7)); - conn.send(durable('transcript.ops', 's1', 8)); - expect(socket.sent).toHaveLength(0); - await vi.advanceTimersByTimeAsync(15); - expect(socket.sent).toHaveLength(0); - await vi.advanceTimersByTimeAsync(1); - - const frames = socket.frames() as Array<{ type: string; seq: number }>; - expect(frames.map((frame) => frame.type)).toEqual(['transcript.reset', 'transcript.ops']); - expect(frames.map((frame) => frame.seq)).toEqual([7, 8]); - conn.close(); - }); - - it('coalesces adjacent subscribed deltas into one socket.send', async () => { - const socket = new FakeSocket(); - const conn = makeConn(socket, { flushIntervalMs: 16 }); - socket.sent = []; - - conn.send(delta('s1', 'main', 1, 'Hello', 0)); - conn.send(delta('s1', 'main', 1, ' ', 5)); - conn.send(delta('s1', 'main', 1, 'world', 6)); - expect(socket.sent).toHaveLength(0); - await vi.advanceTimersByTimeAsync(16); - - const frames = socket.frames(); - expect(frames).toHaveLength(1); - const f = frames[0] as { type: string; offset: number; payload: { delta: string } }; - expect(f.type).toBe('assistant.delta'); - expect(f.offset).toBe(0); - expect(f.payload.delta).toBe('Hello world'); - conn.close(); - }); - - it('sends public events immediately and preserves FIFO with subscribed events', async () => { - const socket = new FakeSocket(); - const conn = makeConn(socket, { flushIntervalMs: 16 }); - socket.sent = []; - - conn.send(delta('s1', 'main', 1, 'before', 0)); - expect(socket.sent).toHaveLength(0); - conn.send(durable('event.session.work_changed', 's1', 2), 'immediate'); - - expect(socket.frames().map((f) => (f as { type: string }).type)).toEqual([ - 'assistant.delta', - 'event.session.work_changed', - ]); - await vi.advanceTimersByTimeAsync(16); - expect(socket.sent).toHaveLength(2); - conn.close(); - }); - - it('flushes immediately once the subscribed batch reaches maxBatchSize', () => { - const socket = new FakeSocket(); - const conn = makeConn(socket, { flushIntervalMs: 1000, maxBatchSize: 3 }); - socket.sent = []; - - conn.send(delta('s1', 'main', 1, 'a', 0)); - conn.send(delta('s1', 'main', 1, 'b', 1)); - conn.send(delta('s1', 'main', 1, 'c', 2)); - - const frames = socket.frames(); - expect(frames).toHaveLength(1); - expect((frames[0] as { payload: { delta: string } }).payload.delta).toBe('abc'); - conn.close(); - }); - - it('defers flushing while the peer is above the watermark, then coalesces on drain', async () => { - const socket = new FakeSocket(); - const conn = makeConn(socket, { - flushIntervalMs: 16, - highWaterMarkBytes: 100, - }); - socket.sent = []; - - socket.bufferedAmount = 200; - conn.send(delta('s1', 'main', 1, 'Hello', 0)); - await vi.advanceTimersByTimeAsync(16); - expect(socket.sent).toHaveLength(0); - - conn.send(delta('s1', 'main', 1, ' world', 5)); - await vi.advanceTimersByTimeAsync(5); - expect(socket.sent).toHaveLength(0); - - socket.bufferedAmount = 0; - await vi.advanceTimersByTimeAsync(5); - const frames = socket.frames(); - expect(frames).toHaveLength(1); - expect((frames[0] as { payload: { delta: string } }).payload.delta).toBe('Hello world'); - conn.close(); - }); - - it('force-flushes buffered subscription frames on close', () => { - const socket = new FakeSocket(); - const conn = makeConn(socket, { flushIntervalMs: 1000 }); - socket.sent = []; - - conn.send(delta('s1', 'main', 1, 'tail', 0)); - expect(socket.sent).toHaveLength(0); - - conn.close(); - const frames = socket.frames(); - expect(frames).toHaveLength(1); - expect((frames[0] as { payload: { delta: string } }).payload.delta).toBe('tail'); - }); - - it('drops buffered frames when the socket is already closed at flush time', async () => { - const socket = new FakeSocket(); - const conn = makeConn(socket, { flushIntervalMs: 16 }); - socket.sent = []; - - socket.readyState = socket.CLOSED; - conn.send(delta('s1', 'main', 1, 'lost', 0)); - await vi.advanceTimersByTimeAsync(16); - expect(socket.sent).toHaveLength(0); - }); -}); - -describe('WsConnectionV1 heartbeat', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - function sentTypes(socket: FakeSocket): string[] { - return socket.frames().map((f) => (f as { type: string }).type); - } - - function sentPings(socket: FakeSocket): Array<{ type: string; payload: { nonce: string } }> { - return socket.frames() as Array<{ type: string; payload: { nonce: string } }>; - } - - it('advertises the heartbeat interval in server_hello', () => { - const socket = new FakeSocket(); - const conn = makeConn(socket, { heartbeatIntervalMs: 10 }); - const hello = socket.frames()[0] as { type: string; payload: { heartbeat_ms?: number } }; - expect(hello.type).toBe('server_hello'); - expect(hello.payload.heartbeat_ms).toBe(10); - conn.close(); - }); - - it('defaults to a 10s heartbeat interval', () => { - const socket = new FakeSocket(); - const conn = makeConn(socket); - const hello = socket.frames()[0] as { payload: { heartbeat_ms?: number } }; - expect(hello.payload.heartbeat_ms).toBe(10_000); - conn.close(); - }); - - it('sends a ping every interval while the peer keeps answering', () => { - const socket = new FakeSocket(); - const conn = makeConn(socket, { heartbeatIntervalMs: 10 }); - socket.sent = []; - - for (let i = 0; i < 3; i++) { - vi.advanceTimersByTime(10); - expect(sentTypes(socket)).toHaveLength(i + 1); - socket.emit('message', JSON.stringify({ type: 'pong', payload: { nonce: 'n' } })); - } - - const pings = sentPings(socket); - expect(pings.every((f) => f.type === 'ping')).toBe(true); - expect(typeof pings[0]!.payload.nonce).toBe('string'); - expect(new Set(pings.map((f) => f.payload.nonce)).size).toBe(3); - expect(socket.closeCalls).toHaveLength(0); - conn.close(); - }); - - it('reaps the connection after two silent cycles', () => { - const socket = new FakeSocket(); - const conn = makeConn(socket, { heartbeatIntervalMs: 10 }); - socket.sent = []; - - vi.advanceTimersByTime(10); - expect(sentTypes(socket)).toEqual(['ping']); - expect(socket.closeCalls).toHaveLength(0); - - vi.advanceTimersByTime(10); - expect(socket.closeCalls).toEqual([{ code: 1001, reason: 'heartbeat timeout' }]); - expect(sentTypes(socket)).toEqual(['ping']); - - vi.advanceTimersByTime(100); - expect(sentTypes(socket)).toEqual(['ping']); - expect(socket.closeCalls).toHaveLength(1); - }); - - it('treats any inbound frame — not just pong — as proof of life', () => { - const socket = new FakeSocket(); - const conn = makeConn(socket, { heartbeatIntervalMs: 10 }); - socket.sent = []; - - vi.advanceTimersByTime(15); - socket.emit('message', JSON.stringify({ type: 'some_future_frame', payload: {} })); - - vi.advanceTimersByTime(20); - expect(sentTypes(socket)).toEqual(['ping', 'ping', 'ping']); - expect(socket.closeCalls).toHaveLength(0); - - vi.advanceTimersByTime(5); - expect(socket.closeCalls).toEqual([{ code: 1001, reason: 'heartbeat timeout' }]); - }); - - it('stops heartbeating once the socket closes on its own', () => { - const socket = new FakeSocket(); - makeConn(socket, { heartbeatIntervalMs: 10 }); - socket.sent = []; - - vi.advanceTimersByTime(10); - expect(sentTypes(socket)).toEqual(['ping']); - - socket.terminate(); - vi.advanceTimersByTime(100); - expect(sentTypes(socket)).toEqual(['ping']); - expect(socket.closeCalls).toHaveLength(0); - }); -}); - -describe('WsConnectionV1 global target registration', () => { - function makeGlobalTargetBroadcaster() { - const added: unknown[] = []; - const removed: unknown[] = []; - const diOptIns: unknown[] = []; - const broadcaster = { - subscribe: async () => true, - unsubscribe: () => {}, - addGlobalTarget: (target: unknown) => added.push(target), - removeGlobalTarget: (target: unknown) => removed.push(target), - addDiEventTarget: (target: unknown) => diOptIns.push(target), - getCursor: async () => ({ seq: 0, epoch: '' }), - getBufferedSince: async () => ({ - events: [], - resyncRequired: false, - currentSeq: 0, - epoch: '', - }), - } as unknown as SessionEventBroadcaster; - return { broadcaster, added, removed, diOptIns }; - } - - it('registers the connection as a global target on construction and unregisters on close', () => { - const socket = new FakeSocket(); - const { broadcaster, added, removed } = makeGlobalTargetBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - - expect(added).toEqual([conn]); - expect(removed).toEqual([]); - - conn.close(); - expect(removed).toEqual([conn]); - }); - - it('unregisters when the socket closes on its own', () => { - const socket = new FakeSocket(); - const { broadcaster, added, removed } = makeGlobalTargetBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - expect(added).toEqual([conn]); - - socket.emit('close'); - expect(removed).toEqual([conn]); - }); - - it('opts only kimi-inspect connections into the event.di.* debug feed on client_hello', async () => { - const socket = new FakeSocket(); - const { broadcaster, diOptIns } = makeGlobalTargetBroadcaster(); - const conn = makeConn(socket, { broadcaster }); - - socket.emit( - 'message', - JSON.stringify({ type: 'client_hello', id: 'h1', payload: { client_id: 'kimi-web' } }), - ); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(diOptIns).toEqual([]); - - socket.emit( - 'message', - JSON.stringify({ - type: 'client_hello', - id: 'h2', - payload: { client_id: 'kimi-inspect' }, - }), - ); - await vi.waitFor(() => expect(diOptIns).toEqual([conn])); - conn.close(); - }); -}); diff --git a/packages/kap-server/test/wsHostOrigin.test.ts b/packages/kap-server/test/wsHostOrigin.test.ts index de63f1b3de0..fc0ba8b6e43 100644 --- a/packages/kap-server/test/wsHostOrigin.test.ts +++ b/packages/kap-server/test/wsHostOrigin.test.ts @@ -49,7 +49,7 @@ function expectRejected(url: string, opts?: ConnectOptions): Promise { describe('WS upgrade Host/Origin checks', () => { let server: RunningServer | undefined; let home: string | undefined; - let v1Url: string; + let v3Url: string; const sockets: WebSocket[] = []; beforeAll(async () => { @@ -62,7 +62,7 @@ describe('WS upgrade Host/Origin checks', () => { logLevel: 'silent', authTokenService: fixedTokenAuth(TOKEN), }); - v1Url = `ws://127.0.0.1:${server.port}/api/v1/ws`; + v3Url = `ws://127.0.0.1:${server.port}/api/v3/ws`; }); afterEach(() => { @@ -85,8 +85,8 @@ describe('WS upgrade Host/Origin checks', () => { } }); - describe('/api/v1/ws', () => { - const url = (): string => v1Url; + describe('/api/v3/ws', () => { + const url = (): string => v3Url; it('rejects a spoofed Host before token validation', async () => { await expectRejected(url(), { headers: { Host: 'evil.com' } }); @@ -114,7 +114,7 @@ describe('WS upgrade Host/Origin checks', () => { authTokenService: fixedTokenAuth(TOKEN), corsOrigins: ['https://app.example.test'], }); - const url = `ws://127.0.0.1:${server.port}/api/v1/ws`; + const url = `ws://127.0.0.1:${server.port}/api/v3/ws`; const ws = await openConn(url, { headers: { origin: 'https://app.example.test' } }); sockets.push(ws); expect(ws.readyState).toBe(WebSocket.OPEN); diff --git a/packages/kap-server/test/wsUpgradeAuth.test.ts b/packages/kap-server/test/wsUpgradeAuth.test.ts index 211a62089b5..4d7ba8a799d 100644 --- a/packages/kap-server/test/wsUpgradeAuth.test.ts +++ b/packages/kap-server/test/wsUpgradeAuth.test.ts @@ -92,17 +92,17 @@ describe('WS upgrade auth', () => { } }); - function v1Url(): string { - return `${sharedServer().base.replace(/^http/, 'ws')}/api/v1/ws`; + function v3Url(): string { + return `${sharedServer().base.replace(/^http/, 'ws')}/api/v3/ws`; } function token(): string { return sharedServer().token; } - describe('/api/v1/ws', () => { - const firstType = 'server_hello'; - const url = (): string => v1Url(); + describe('/api/v3/ws', () => { + const firstType = 'hello'; + const url = (): string => v3Url(); it('accepts a valid bearer subprotocol and echoes it', async () => { const { ws, firstFrame } = await openConn(url(), { @@ -182,7 +182,9 @@ describe('WS upgrade auth', () => { }); it('rejects upgrades to a non-WS path', async () => { - const badUrl = `${v1Url().replace('/api/v1/ws', '/api/v1/other')}`; + const badUrl = `${v3Url().replace('/api/v3/ws', '/api/v1/other')}`; await expectRejected(badUrl, { protocols: [`kimi-code.bearer.${token()}`] }); + const goneUrl = `${v3Url().replace('/api/v3/ws', '/api/v1/ws')}`; + await expectRejected(goneUrl, { protocols: [`kimi-code.bearer.${token()}`] }); }); }); diff --git a/packages/kap-server/test/wsV1Resync.test.ts b/packages/kap-server/test/wsV1Resync.test.ts deleted file mode 100644 index 2bef30a62a0..00000000000 --- a/packages/kap-server/test/wsV1Resync.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { - type Event2, - IEventBus, - IAgentLifecycleService, - getLiveSessionById, -} from '@moonshot-ai/agent-core-v2'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { WebSocket } from 'ws'; - -import { type RunningServer, startServer } from '../src/start'; -import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; -import { authHeaders } from './helpers/auth'; - -interface Frame { - type: string; - id?: string; - seq?: number; - session_id?: string; - payload?: Record; - volatile?: boolean; - offset?: number; -} - -interface Conn { - ws: WebSocket; - frames: Frame[]; - waiters: Array<(f: Frame) => void>; - closed: Promise; - send: (f: unknown) => void; - next: (pred: (f: Frame) => boolean, timeoutMs?: number) => Promise; -} - -function openConn(url: string, token: string): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(url, [`kimi-code.bearer.${token}`]); - const frames: Frame[] = []; - const waiters: Array<(f: Frame) => void> = []; - const closed = new Promise((res) => ws.on('close', () => res())); - ws.on('message', (data) => { - let frame: Frame; - try { - frame = JSON.parse((data as Buffer).toString()) as Frame; - } catch { - return; - } - const w = waiters.shift(); - if (w) w(frame); - else frames.push(frame); - }); - ws.once('open', () => - resolve({ - ws, - frames, - waiters, - closed, - send: (f) => ws.send(JSON.stringify(f)), - next: (pred, timeoutMs = 2000) => - new Promise((res, rej) => { - const idx = frames.findIndex(pred); - if (idx >= 0) { - res(frames.splice(idx, 1)[0]!); - return; - } - const deadline = Date.now() + timeoutMs; - let t: ReturnType; - const waiter = (f: Frame): void => { - clearTimeout(t); - if (pred(f)) res(f); - else { - frames.push(f); - waiters.push(waiter); - arm(); - } - }; - const arm = (): void => { - const left = deadline - Date.now(); - if (left <= 0) { - const i = waiters.indexOf(waiter); - if (i >= 0) waiters.splice(i, 1); - rej(new Error('timeout waiting for frame')); - return; - } - t = setTimeout(() => { - const i = waiters.indexOf(waiter); - if (i >= 0) waiters.splice(i, 1); - rej(new Error('timeout waiting for frame')); - }, left); - }; - arm(); - waiters.push(waiter); - }), - }), - ); - ws.once('error', reject); - }); -} - -describe('server-v2 /api/v1/ws resync', () => { - let server: RunningServer | undefined; - let home: string | undefined; - let base: string; - let wsUrl: string; - - beforeAll(async () => { - home = await mkdtemp(join(tmpdir(), 'kimi-wsv1-test-')); - server = await startServer({ hostIdentity: TEST_HOST_IDENTITY, host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - base = `http://127.0.0.1:${server.port}`; - wsUrl = `ws://127.0.0.1:${server.port}/api/v1/ws`; - }); - - afterAll(async () => { - if (server !== undefined) { - await server.close(); - server = undefined; - } - if (home !== undefined) { - await rm(home, { recursive: true, force: true }); - home = undefined; - } - }); - - async function createSession(): Promise { - const res = await fetch(`${base}/api/v1/sessions`, { - method: 'POST', - headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), - body: JSON.stringify({ metadata: { cwd: home } }), - } as never); - const body = (await res.json()) as { code: number; data: { id: string } }; - expect(body.code).toBe(0); - return body.data.id; - } - - async function ensureMainAgent(sessionId: string): Promise { - const session = getLiveSessionById(server!.core.accessor, sessionId); - expect(session).toBeDefined(); - const agents = session!.accessor.get(IAgentLifecycleService); - if (agents.handleOf('main') === undefined) { - await agents.create({ agentId: 'main' }); - } - } - - function withToken>(payload: T): T & { token: string } { - return { ...payload, token: server!.authTokenService.getToken() }; - } - - function emitAgentEvent(sessionId: string, event: Event2): void { - const session = getLiveSessionById(server!.core.accessor, sessionId); - expect(session).toBeDefined(); - const agents = session!.accessor.get(IAgentLifecycleService); - const main = agents.handleOf('main'); - expect(main).toBeDefined(); - main!.accessor.get(IEventBus).publish(event); - } - - it('server_hello then client_hello ack with accepted subscription', async () => { - const sid = await createSession(); - const c = await openConn(wsUrl, server!.authTokenService.getToken()); - - const hello = await c.next((f) => f.type === 'server_hello'); - expect(hello.payload).toMatchObject({ protocol_version: 2 }); - - c.send({ - type: 'client_hello', - id: 'h1', - payload: withToken({ client_id: 'cli', subscriptions: [sid] }), - }); - const ack = await c.next((f) => f.type === 'ack' && f.id === 'h1'); - expect(ack.payload).toMatchObject({ accepted_subscriptions: [sid], resync_required: [] }); - - c.ws.close(); - await c.closed; - }); - - it('delivers a sequenced durable event to a subscribed connection', async () => { - const sid = await createSession(); - await ensureMainAgent(sid); - const c = await openConn(wsUrl, server!.authTokenService.getToken()); - await c.next((f) => f.type === 'server_hello'); - c.send({ type: 'client_hello', id: 'h1', payload: withToken({ client_id: 'cli', subscriptions: [sid] }) }); - await c.next((f) => f.type === 'ack' && f.id === 'h1'); - - emitAgentEvent(sid, { type: 'turn.started', turnId: 1 } as unknown as Event2); - - const ev = await c.next((f) => f.type === 'turn.started'); - expect(ev.seq).toBeGreaterThanOrEqual(1); - expect(ev.session_id).toBe(sid); - expect(ev.volatile).toBeUndefined(); - - c.ws.close(); - await c.closed; - }); - - it('replays durable events since a cursor on reconnect', async () => { - const sid = await createSession(); - await ensureMainAgent(sid); - - const c1 = await openConn(wsUrl, server!.authTokenService.getToken()); - await c1.next((f) => f.type === 'server_hello'); - c1.send({ type: 'client_hello', id: 'h1', payload: withToken({ client_id: 'cli', subscriptions: [sid] }) }); - await c1.next((f) => f.type === 'ack' && f.id === 'h1'); - emitAgentEvent(sid, { type: 'turn.started', turnId: 1 } as unknown as Event2); - emitAgentEvent(sid, { type: 'turn.ended', turnId: 1 } as unknown as Event2); - await c1.next((f) => f.type === 'turn.ended'); - c1.ws.close(); - await c1.closed; - - const c2 = await openConn(wsUrl, server!.authTokenService.getToken()); - await c2.next((f) => f.type === 'server_hello'); - c2.send({ - type: 'client_hello', - id: 'h2', - payload: withToken({ client_id: 'cli', subscriptions: [sid], cursors: { [sid]: { seq: 1 } } }), - }); - const replayed = await c2.next((f) => f.type === 'turn.ended'); - expect(replayed.seq).toBeGreaterThanOrEqual(2); - const ack2 = await c2.next((f) => f.type === 'ack' && f.id === 'h2'); - expect(ack2.payload).toMatchObject({ accepted_subscriptions: [sid] }); - - c2.ws.close(); - await c2.closed; - }); - - it('sends resync_required on epoch mismatch', async () => { - const sid = await createSession(); - const c = await openConn(wsUrl, server!.authTokenService.getToken()); - await c.next((f) => f.type === 'server_hello'); - c.send({ - type: 'client_hello', - id: 'h1', - payload: withToken({ - client_id: 'cli', - subscriptions: [sid], - cursors: { [sid]: { seq: 0, epoch: 'ep_wrong' } }, - }), - }); - const rs = await c.next((f) => f.type === 'resync_required'); - expect(rs.payload).toMatchObject({ session_id: sid, reason: 'epoch_changed' }); - - c.ws.close(); - await c.closed; - }); - - it('delivers only the allowlisted agent events via agent_filter', async () => { - const sid = await createSession(); - await ensureMainAgent(sid); - - const session = getLiveSessionById(server!.core.accessor, sid); - expect(session).toBeDefined(); - const agents = session!.accessor.get(IAgentLifecycleService); - await agents.create({ agentId: 'agent-0' }); - const sub = agents.handleOf('agent-0')!; - - const c = await openConn(wsUrl, server!.authTokenService.getToken()); - await c.next((f) => f.type === 'server_hello'); - c.send({ - type: 'client_hello', - id: 'h1', - payload: withToken({ - client_id: 'cli', - subscriptions: [sid], - agent_filter: { [sid]: ['main'] }, - }), - }); - await c.next((f) => f.type === 'ack' && f.id === 'h1'); - - agents.handleOf('main')! - .accessor.get(IEventBus) - .publish({ type: 'turn.ended', turnId: 1 } as unknown as Event2); - sub.accessor - .get(IEventBus) - .publish({ type: 'turn.ended', turnId: 2 } as unknown as Event2); - - const ev = await c.next((f) => f.type === 'turn.ended'); - expect(ev.payload).toMatchObject({ agentId: 'main' }); - - await expect(c.next((f) => f.type === 'turn.ended', 300)).rejects.toThrow(); - - c.ws.close(); - await c.closed; - }); -}); diff --git a/packages/kap-server/test/wsV3.test.ts b/packages/kap-server/test/wsV3.test.ts index 794be6f5db7..81aaf12d96f 100644 --- a/packages/kap-server/test/wsV3.test.ts +++ b/packages/kap-server/test/wsV3.test.ts @@ -128,6 +128,7 @@ class FakeGlobalSource { workspaces: Workspace[] = []; sessionInfoResult: unknown; private readonly cbs = new Set<(event: WsV3CoreEvent) => void>(); + private readonly activityCbs = new Set<(sessionId: string) => void>(); subscribe(cb: (event: WsV3CoreEvent) => void): IDisposable { this.cbs.add(cb); @@ -142,6 +143,19 @@ class FakeGlobalSource { for (const cb of [...this.cbs]) cb(event); } + watchSessionActivity(cb: (sessionId: string) => void): IDisposable { + this.activityCbs.add(cb); + return { + dispose: () => { + this.activityCbs.delete(cb); + }, + }; + } + + fireActivity(sessionId: string): void { + for (const cb of [...this.activityCbs]) cb(sessionId); + } + async listWorkspaces(): Promise { return this.workspaces; } @@ -259,7 +273,6 @@ function sessionInfoWire(id: string): Record { }, permission_rules: [], message_count: 0, - last_seq: 0, }; } @@ -699,6 +712,62 @@ describe('WsV3 global message fanout', () => { }); }); + it('emits session updated on session activity changes', async () => { + const { globalSource, hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + await settle(); + + globalSource.sessionInfoResult = sessionInfoWire('s1'); + globalSource.fire({ + type: 'event.session.created', + payload: { sessionId: 's1', session: sessionInfoWire('s1') }, + }); + globalSource.fireActivity('s1'); + await settle(); + + const frames = socket.frames(); + expect(frames[2]).toMatchObject({ + type: 'session', + subtype: 'updated', + session: { id: 's1' }, + }); + + globalSource.sessionInfoResult = undefined; + globalSource.fireActivity('s9'); + await settle(); + expect(socket.frames()).toHaveLength(3); + }); + + it('emits session deleted only for sessions seen before', async () => { + const { globalSource, hub } = makeHarness(); + const socket = new FakeSocket(); + makeConn(hub, socket); + await settle(); + + globalSource.fire({ + type: 'event.session.created', + payload: { sessionId: 's1', session: sessionInfoWire('s1') }, + }); + globalSource.fire({ + type: 'event.session.deleted', + payload: { sessionId: 's1', workspaceId: WS_ID }, + }); + globalSource.fire({ + type: 'event.session.deleted', + payload: { sessionId: 's2', workspaceId: WS_ID }, + }); + await settle(); + + const frames = socket.frames(); + expect(frames).toHaveLength(3); + expect(frames[2]).toMatchObject({ + type: 'session', + subtype: 'deleted', + session: { id: 's1' }, + }); + }); + it('drops global messages that fail outbound schema validation and logs telemetry', async () => { const { globalSource, hub, warnings } = makeHarness(); const socket = new FakeSocket(); diff --git a/packages/klient/AGENTS.md b/packages/klient/AGENTS.md index 127bd662e87..559f0e6b66a 100644 --- a/packages/klient/AGENTS.md +++ b/packages/klient/AGENTS.md @@ -29,7 +29,7 @@ The facade only covers services that behave identically on both transports `main`-agent materialization via `ensureMainAgent`). onWill/hook-style interception is not wire-exposable (engine hooks are in-process `OrderedHookSlot`s); the terminal surface is -v1-only and lives in the legacy suites. File upload IS on the facade +not on the facade. File upload IS on the facade (`global.files`): bytes cross the wire base64-encoded and the dispatcher adapts the engine's `IFileService` streams in both directions. @@ -38,34 +38,11 @@ adapts the engine's `IFileService` streams in both directions. - One shared conformance suite (`test/helpers/conformance.ts`) runs unchanged against every transport — one test file per transport under `test/`. Add new **global** facade coverage there, not per-transport. -- `test/e2e/legacy/` + `test/e2e/harness/` — the legacy `/api/v1` live - suites (moved from server-e2e). They skip unless `KIMI_SERVER_URL` points - at a running server and **must keep running unchanged**; the v1 surface - has no in-memory equivalent, so these stay live-server-only — do not try - to run them against the in-process transports. -- The retired `scenarios/` scripts were rewritten as suites: image-upload - and terminal (v1-only surfaces) live in `test/e2e/legacy/`. - -## Observability (inherited from server-e2e) - -- Keep observability inside each e2e case; every live case prints structured, - case-scoped details (requests, envelopes, WS handshakes, terminal frames, - error envelopes) through the shared logger in `test/e2e/legacy/log.ts`, - not ad hoc `console.log`. -- Logs must stay visible for passing Vitest cases — write through stdout. -- When adding or changing an e2e case, update its observability at the same - time; do not add a scenario solely to print data an existing case should - already expose. ## Command reference - `pnpm --filter @moonshot-ai/klient test` — all Vitest suites (unit + - conformance + e2e; live cases skip without their env). -- `KIMI_SERVER_URL=http://127.0.0.1:58627 pnpm --filter @moonshot-ai/klient test` - — include the live legacy cases against a running server. -- `pnpm --filter @moonshot-ai/klient docker:e2e` — docker e2e; the run - derives its runner name/namespace from the current workspace to avoid - cross-workspace conflicts. + conformance + e2e). - `pnpm --filter @moonshot-ai/klient typecheck` / `pnpm smoke` (in-process smoke over the memory transport; see `examples/smoke.ts`). - `pnpm --filter @moonshot-ai/klient smoke:boundary` — ModelRequester boundary diff --git a/packages/klient/Dockerfile b/packages/klient/Dockerfile deleted file mode 100644 index 98473034c4a..00000000000 --- a/packages/klient/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# syntax=docker/dockerfile:1.7 -# -# klient e2e docker-run image. -# -# This image layers server-e2e defaults on top of the repository server dev -# image. Source code and node_modules are still provided by bind mounts from -# scripts/run-docker-e2e.sh, so host edits are picked up without rebuilding. -# -# The launcher intentionally does not pass -p/--publish to docker run. The -# server binds inside the container only, so this workflow can run alongside the -# docker-compose.yml server that publishes host port 7878. - -ARG BASE_IMAGE=kimi-server:dev -FROM ${BASE_IMAGE} - -ENV KIMI_CODE_HOME=/data/docker-e2e/kimi-code-home \ - KIMI_SERVER_URL=http://127.0.0.1:7878 \ - KIMI_SERVER_E2E_REPORT_DIR=/data/server-e2e-reports/docker/latest \ - TMPDIR=/data/docker-e2e/tmp \ - TERM=xterm-256color \ - TZ=Asia/Shanghai \ - npm_config_store_dir=/workspace/kimi-code/node_modules/.pnpm-store \ - npm_config_package_import_method=copy - -WORKDIR /workspace/kimi-code/packages/klient - -CMD ["bash"] diff --git a/packages/klient/README.md b/packages/klient/README.md index 25656a5d171..43baaa027a3 100644 --- a/packages/klient/README.md +++ b/packages/klient/README.md @@ -76,17 +76,6 @@ ships with the transport: `serveKlientIpc({ scope, socketPath })`. The same conformance suite runs against both transports in this package's tests (`test/helpers/conformance.ts` — one test file per transport). -This package also hosts the e2e suites (the retired `server-e2e` package was -folded in here): - -- `test/e2e/legacy/` + `test/e2e/harness/` — the legacy `/api/v1` live suites - and their client harness (skip unless `KIMI_SERVER_URL` is set; the v1 - surface has no in-memory equivalent, so these stay live-server-only). - -The docker e2e runner (`pnpm docker:e2e`) runs this whole vitest suite inside -a container against a container-local server. See `AGENTS.md` for the testing -rules. - ## Scope The facade covers the global (app), session, and agent surfaces shown above. diff --git a/packages/klient/package.json b/packages/klient/package.json index e8525949d0a..d724f0a851d 100644 --- a/packages/klient/package.json +++ b/packages/klient/package.json @@ -39,17 +39,10 @@ "smoke:boundary": "tsx --tsconfig ./tsconfig.examples.json --import ../../build/register-raw-text-loader.mjs examples/model-requester-boundary.ts", "smoke:select-tools": "tsx --tsconfig ./tsconfig.examples.json --import ../../build/register-raw-text-loader.mjs examples/kimi-select-tools.ts", "stress:kosong-config": "tsx --tsconfig ./tsconfig.examples.json --import ../../build/register-raw-text-loader.mjs examples/kosong-config-stress.ts", - "clean": "rm -rf dist", - "docker:e2e": "bash scripts/run-docker-e2e.sh" + "clean": "rm -rf dist" }, "dependencies": { "@moonshot-ai/agent-core-v2": "workspace:^", "zod": "catalog:" - }, - "devDependencies": { - "@moonshot-ai/kap-server": "workspace:^", - "@types/ws": "^8.18.0", - "ulid": "^3.0.1", - "ws": "^8.18.0" } } diff --git a/packages/klient/scripts/run-docker-e2e.sh b/packages/klient/scripts/run-docker-e2e.sh deleted file mode 100755 index 66945afbc05..00000000000 --- a/packages/klient/scripts/run-docker-e2e.sh +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -PACKAGE_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" -REPO_ROOT="$(cd -- "${PACKAGE_DIR}/../.." && pwd)" - -workspace_slug="$( - basename -- "${REPO_ROOT}" \ - | tr '[:upper:]' '[:lower:]' \ - | tr -cs 'a-z0-9_.-' '-' \ - | sed -e 's/^[^a-z0-9]*//' -e 's/[^a-z0-9]*$//' \ - | cut -c1-48 -)" -if [[ -z "${workspace_slug}" ]]; then - workspace_slug="workspace" -fi -workspace_hash="$(printf '%s' "${REPO_ROOT}" | cksum | awk '{print $1}')" -RUN_ID="${KIMI_SERVER_E2E_RUN_ID:-${workspace_slug}-${workspace_hash}}" - -BASE_IMAGE="${KIMI_SERVER_E2E_BASE_IMAGE:-kimi-server-e2e-base:${RUN_ID}}" -IMAGE="${KIMI_SERVER_E2E_IMAGE:-kimi-server-e2e:${RUN_ID}}" -CONTAINER="${KIMI_SERVER_E2E_CONTAINER:-kimi-server-e2e-${RUN_ID}}" -STATE_ROOT="${KIMI_SERVER_E2E_STATE_ROOT:-${HOME}/.kimi-code-server-dev}" -PORT="${KIMI_SERVER_E2E_PORT:-58627}" - -KIMI_HOME_HOST="${KIMI_SERVER_E2E_KIMI_HOME_HOST:-${STATE_ROOT}/docker-e2e/${RUN_ID}/kimi-code-home}" -KIMI_HOME_CONTAINER="/data/docker-e2e/kimi-code-home" -SEED_HOME_HOST="${KIMI_SERVER_E2E_SEED_KIMI_HOME_HOST:-${STATE_ROOT}/kimi-home/kimi-code-home}" - -if [[ -n "${KIMI_SERVER_E2E_REPORT_DIR_HOST:-}" ]]; then - REPORT_DIR_HOST="${KIMI_SERVER_E2E_REPORT_DIR_HOST}" - REPORT_ROOT_HOST="$(dirname -- "${REPORT_DIR_HOST}")" - REPORT_DIR_NAME="$(basename -- "${REPORT_DIR_HOST}")" -else - REPORT_ROOT_HOST="${KIMI_SERVER_E2E_REPORT_ROOT_HOST:-${STATE_ROOT}/server-e2e-reports/docker/${RUN_ID}}" - REPORT_DIR_NAME="latest" - REPORT_DIR_HOST="${REPORT_ROOT_HOST}/${REPORT_DIR_NAME}" -fi -REPORT_ROOT_CONTAINER="/data/server-e2e-reports/docker" -REPORT_DIR_CONTAINER="${REPORT_ROOT_CONTAINER}/${REPORT_DIR_NAME}" -TMPDIR_CONTAINER="/data/docker-e2e/tmp" - -NM_ROOT="${STATE_ROOT}/docker-e2e/${RUN_ID}/nm" - -workspace_node_modules=( - "root:/workspace/kimi-code/node_modules" - "apps_kimi-code:/workspace/kimi-code/apps/kimi-code/node_modules" - "apps_kimi-web:/workspace/kimi-code/apps/kimi-web/node_modules" - "apps_vis:/workspace/kimi-code/apps/vis/node_modules" - "apps_vis_server:/workspace/kimi-code/apps/vis/server/node_modules" - "apps_vis_web:/workspace/kimi-code/apps/vis/web/node_modules" - "docs:/workspace/kimi-code/docs/node_modules" - "pkg_kap-server:/workspace/kimi-code/packages/kap-server/node_modules" - "pkg_server-e2e:/workspace/kimi-code/packages/klient/node_modules" - "pkg_kaos:/workspace/kimi-code/packages/kaos/node_modules" - "pkg_kosong:/workspace/kimi-code/packages/kosong/node_modules" - "pkg_migration-legacy:/workspace/kimi-code/packages/migration-legacy/node_modules" - "pkg_node-sdk:/workspace/kimi-code/packages/node-sdk/node_modules" - "pkg_oauth:/workspace/kimi-code/packages/oauth/node_modules" - "pkg_protocol:/workspace/kimi-code/packages/protocol/node_modules" - "pkg_services:/workspace/kimi-code/packages/services/node_modules" - "pkg_telemetry:/workspace/kimi-code/packages/telemetry/node_modules" -) - -mkdir -p "${STATE_ROOT}" "${KIMI_HOME_HOST}" "${REPORT_DIR_HOST}" "${NM_ROOT}" -for mount in "${workspace_node_modules[@]}"; do - mkdir -p "${NM_ROOT}/${mount%%:*}" -done - -# Seed only auth/config into the isolated docker-e2e home. Never copy server -# locks, sessions, uploaded files, or reports from the compose server home. -if [[ -f "${SEED_HOME_HOST}/config.toml" && ! -f "${KIMI_HOME_HOST}/config.toml" ]]; then - cp "${SEED_HOME_HOST}/config.toml" "${KIMI_HOME_HOST}/config.toml" -fi -if [[ -d "${SEED_HOME_HOST}/credentials" && ! -d "${KIMI_HOME_HOST}/credentials" ]]; then - cp -R "${SEED_HOME_HOST}/credentials" "${KIMI_HOME_HOST}/credentials" -fi - -if [[ "${KIMI_SERVER_E2E_SKIP_BUILD:-0}" != "1" ]]; then - docker build -t "${BASE_IMAGE}" -f "${REPO_ROOT}/Dockerfile" "${REPO_ROOT}" - docker build \ - -t "${IMAGE}" \ - -f "${PACKAGE_DIR}/Dockerfile" \ - --build-arg "BASE_IMAGE=${BASE_IMAGE}" \ - "${REPO_ROOT}" -fi - -docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true - -read -r -d '' container_script <<'EOS' || true -set -euo pipefail - -cd /workspace/kimi-code -mkdir -p "${KIMI_CODE_HOME}/server" "${KIMI_SERVER_E2E_REPORT_DIR}" "${TMPDIR}" /data/server-e2e-reports/docker -rm -f "${KIMI_CODE_HOME}/server/lock" - -if [[ ! -e /workspace/kimi-code/node_modules/.modules.yaml || ! -e /workspace/kimi-code/packages/klient/node_modules/ws ]]; then - echo "[server-e2e:docker] installing pnpm deps" - pnpm install --frozen-lockfile -else - echo "[server-e2e:docker] pnpm deps already present" -fi - -server_log="/data/server-e2e-reports/docker/server.log" -: > "${server_log}" - -echo "[server-e2e:docker] starting server on container-local ${KIMI_SERVER_URL}" -pnpm dev:server -- \ - --host 127.0.0.1 \ - --port "${KIMI_SERVER_E2E_PORT}" \ - --log-level debug \ - --debug-endpoints \ - >"${server_log}" 2>&1 & -server_pid=$! - -cleanup() { - status=$? - if kill -0 "${server_pid}" >/dev/null 2>&1; then - kill "${server_pid}" >/dev/null 2>&1 || true - wait "${server_pid}" >/dev/null 2>&1 || true - fi - exit "${status}" -} -trap cleanup EXIT INT TERM - -ready=0 -for attempt in $(seq 1 90); do - if curl -fsS "${KIMI_SERVER_URL}/api/v1/meta" >/tmp/server-meta.json 2>/tmp/server-curl.err; then - ready=1 - echo "[server-e2e:docker] server ready: $(cat /tmp/server-meta.json)" - break - fi - if ! kill -0 "${server_pid}" >/dev/null 2>&1; then - echo "[server-e2e:docker] server exited before readiness" >&2 - tail -n 200 "${server_log}" >&2 || true - exit 1 - fi - sleep 1 -done - -if [[ "${ready}" != "1" ]]; then - echo "[server-e2e:docker] server did not become ready within 90s" >&2 - cat /tmp/server-curl.err >&2 || true - tail -n 200 "${server_log}" >&2 || true - exit 1 -fi - -cd /workspace/kimi-code/packages/klient -pnpm test -EOS - -docker_args=( - run - --rm - --init - --name "${CONTAINER}" - --workdir /workspace/kimi-code/packages/klient - --env "KIMI_CODE_HOME=${KIMI_HOME_CONTAINER}" - --env "KIMI_SERVER_E2E_PORT=${PORT}" - --env "KIMI_SERVER_URL=http://127.0.0.1:${PORT}" - --env "KIMI_SERVER_E2E_REPORT_DIR=${REPORT_DIR_CONTAINER}" - --env "TMPDIR=${TMPDIR_CONTAINER}" - --env "TERM=xterm-256color" - --env "TZ=Asia/Shanghai" - --env "npm_config_store_dir=/workspace/kimi-code/node_modules/.pnpm-store" - --env "npm_config_package_import_method=copy" - --volume "${REPO_ROOT}:/workspace/kimi-code:ro" - --volume "${KIMI_HOME_HOST}:${KIMI_HOME_CONTAINER}" - --volume "${REPORT_ROOT_HOST}:${REPORT_ROOT_CONTAINER}" -) - -for mount in "${workspace_node_modules[@]}"; do - docker_args+=(--volume "${NM_ROOT}/${mount%%:*}:${mount#*:}") -done - -echo "[server-e2e:docker] running ${IMAGE} without host port publishing" -set +e -docker "${docker_args[@]}" "${IMAGE}" bash -lc "${container_script}" -status=$? -set -e - -echo "[server-e2e:docker] report: ${REPORT_DIR_HOST}/index.html" -echo "[server-e2e:docker] server log: ${REPORT_ROOT_HOST}/server.log" -exit "${status}" diff --git a/packages/klient/test/e2e/harness/client.ts b/packages/klient/test/e2e/harness/client.ts deleted file mode 100644 index b18664d01f2..00000000000 --- a/packages/klient/test/e2e/harness/client.ts +++ /dev/null @@ -1,706 +0,0 @@ -/** - * `DaemonClient` — wire-level test client for the kimi-code server. - * - * Wraps the server's HTTP REST + WS surfaces (`/api/v1/...` + `/api/v1/ws`) - * into a single, typed object that scenarios can drive. Handles: - * - Envelope unwrap + typed REST helpers - * - WS `server_hello` → `client_hello` → ack handshake - * - `subscribe` / `unsubscribe` ack correlation - * - Approval + question reverse-RPC auto-resolve via per-event handlers - * - `waitForFrame` / `waitForSessionBusy` convenience waits - * - * **What it is NOT**: a server bootstrap helper. Connect to a server process - * that's already running at `baseUrl` (default `http://127.0.0.1:58627`). - */ -import type { - FsBrowseResponse, - FsHomeResponse, -} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; -import type { AuthSummary } from '@moonshot-ai/agent-core-v2/app/authLegacy/authLegacy'; -import type { FileMeta } from '@moonshot-ai/agent-core-v2/app/file/fileService'; -import type { UpdateSessionProfileRequest as SessionUpdate } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; -import type { - ProviderCatalogItem, - SetDefaultModelResponse, -} from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; -import type { Terminal } from '@moonshot-ai/agent-core-v2/os/interface/terminal'; -import type { - ApprovalRequest, - ApprovalResponse, -} from '@moonshot-ai/kap-server/protocol/approval'; -import type { Message } from '@moonshot-ai/kap-server/protocol/message'; -import type { - QuestionRequest, - QuestionResponse, -} from '@moonshot-ai/kap-server/protocol/question'; -import type { - ApprovalResolveResult, - ListPendingApprovalsResponse, -} from '@moonshot-ai/kap-server/protocol/rest-approval'; -import type { - ListModelsResponse, - ListProvidersResponse, -} from '@moonshot-ai/kap-server/protocol/rest-modelCatalog'; -import type { - PromptAbortResponse, - PromptListResponse, - PromptPermissionMode, - PromptSubmission, - PromptSteerResult, - PromptSubmitResult, - PromptThinking, -} from '@moonshot-ai/kap-server/protocol/rest-prompt'; -import type { - ListPendingQuestionsResponse, - QuestionResolveResult, -} from '@moonshot-ai/kap-server/protocol/rest-question'; -import type { - CompactSessionRequest, - CompactSessionResponse, - ForkSessionRequest, - SessionAbortResponse, - UndoSessionRequest, - UndoSessionResponse, -} from '@moonshot-ai/kap-server/protocol/rest-session'; -import type { - CloseTerminalResponse, - CreateTerminalRequest, - ListTerminalsResponse, -} from '@moonshot-ai/kap-server/protocol/rest-terminal'; -import type { - Session, - SessionChildCreate, - SessionCreate, -} from '@moonshot-ai/kap-server/protocol/session'; -import type { - Workspace, - WorkspaceCreate, - WorkspaceUpdate, -} from '@moonshot-ai/kap-server/protocol/workspace'; -import type { ServerHelloMessage } from '@moonshot-ai/kap-server/protocol/ws-control'; -import { ulid } from 'ulid'; -import { WebSocket as WsWebSocket } from 'ws'; - -import { HttpClient } from './http.js'; -import { installReverseRpcHandler } from './reverse-rpc.js'; -import { DEFAULT_FRAME_TIMEOUT_MS, waitForSessionBusy } from './wait.js'; -import { type AnyFrame, WsClient } from './ws.js'; - -export interface DaemonClientOptions { - /** Default `http://127.0.0.1:58627`. */ - baseUrl?: string; - /** Default `/api/v1`. WS endpoint is `${apiPrefix}/ws`. */ - apiPrefix?: string; - /** Default `server-e2e-` — used as the `client_hello.client_id`. */ - clientId?: string; - fetchImpl?: typeof fetch; - wsImpl?: typeof WsWebSocket; - logger?: (level: 'info' | 'warn' | 'error' | 'debug', msg: string, meta?: unknown) => void; - /** Directory for JSONL trace events and generated HTML reports. */ - reportDir?: string; - /** Default 5s. Applies to handshake + subscribe acks. */ - controlAckTimeoutMs?: number; -} - -export interface SubmitAndWaitOptions { - /** Default `prompt.completed`. */ - waitFor?: 'prompt.completed' | 'turn.ended'; - /** Default 60s. */ - timeoutMs?: number; -} - -type UploadFileData = Blob | ArrayBuffer | Uint8Array | string; - -const DEFAULT_BASE_URL = 'http://127.0.0.1:58627'; -const DEFAULT_API_PREFIX = '/api/v1'; -const DEFAULT_CONTROL_ACK_TIMEOUT_MS = 5_000; - -/** - * Per-request stateless session controls that the server REST surface - * requires on every prompt submission. Scenarios that don't care about - * these can leave them at the defaults; tests that exercise switching - * model / thinking / permission / plan mode override only the field - * they need. - * - * `model` matches what the existing server-e2e scenarios assume (the - * default provider exposes `kimi-code/kimi-for-coding`). - */ -export const DEFAULT_PROMPT_CONTROLS = { - model: 'kimi-code/kimi-for-coding', - thinking: 'off' as PromptThinking, - permission_mode: 'manual' as PromptPermissionMode, - plan_mode: false, -} as const; - -/** - * Looser input shape for `submitPrompt` / `submitAndWait`. `content` is - * required; the four stateless controls fall back to - * `DEFAULT_PROMPT_CONTROLS` when omitted. `metadata` carries through - * verbatim. - */ -export type PromptSubmitInput = - Pick - & Partial>; - -export interface TerminalAttachOptions { - sinceSeq?: number; - timeoutMs?: number; -} - -export interface TerminalControlOptions { - timeoutMs?: number; -} - -export interface TerminalAttachResult { - attached: true; - replayed: number; -} - -export interface TerminalDetachResult { - detached: true; -} - -export interface TerminalInputResult { - accepted: true; -} - -export interface TerminalResizeResult { - resized: true; -} - -export interface TerminalCloseResult { - closed: true; -} - -function fillPromptDefaults(input: PromptSubmitInput): PromptSubmission { - return { ...DEFAULT_PROMPT_CONTROLS, ...input }; -} - -export class DaemonClient { - readonly baseUrl: string; - readonly apiPrefix: string; - readonly clientId: string; - readonly http: HttpClient; - - private readonly _wsImpl: typeof WsWebSocket; - private readonly _logger: ( - level: 'info' | 'warn' | 'error' | 'debug', - msg: string, - meta?: unknown, - ) => void; - private readonly _reportDir: string | undefined; - private readonly _controlAckTimeoutMs: number; - private _ws: WsClient | null = null; - private _serverHello: ServerHelloMessage['payload'] | null = null; - private readonly _subscribed = new Set(); - private readonly _disposers: Array<() => void> = []; - - constructor(opts: DaemonClientOptions = {}) { - this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); - this.apiPrefix = opts.apiPrefix ?? DEFAULT_API_PREFIX; - this.clientId = opts.clientId ?? `server-e2e-${ulid()}`; - this._wsImpl = opts.wsImpl ?? WsWebSocket; - this._logger = opts.logger ?? noopLogger; - this._reportDir = opts.reportDir; - this._controlAckTimeoutMs = opts.controlAckTimeoutMs ?? DEFAULT_CONTROL_ACK_TIMEOUT_MS; - this.http = new HttpClient({ - baseUrl: this.baseUrl, - apiPrefix: this.apiPrefix, - fetchImpl: opts.fetchImpl ?? fetch, - reportDir: this._reportDir, - }); - } - - // ── Probes + model catalog ───────────────────────────────────────────── - getAuth(): Promise { - return this.http.getAuth(); - } - listModels(): Promise { - return this.http.listModels(); - } - setDefaultModel(modelId: string): Promise { - return this.http.setDefaultModel(modelId); - } - listProviders(): Promise { - return this.http.listProviders(); - } - getProvider(providerId: string): Promise { - return this.http.getProvider(providerId); - } - - // ── HTTP convenience surface ──────────────────────────────────────────── - createSession(body: SessionCreate): Promise { - return this.http.createSession(body); - } - getSession(sid: string): Promise { - return this.http.getSession(sid); - } - listSessions( - query?: { page_size?: number; before_id?: string; after_id?: string; workspace_id?: string }, - ): Promise<{ items: Session[]; has_more: boolean }> { - return this.http.listSessions(query); - } - updateSession(sid: string, body: SessionUpdate): Promise { - return this.http.updateSession(sid, body); - } - forkSession(sid: string, body: ForkSessionRequest = {}): Promise { - return this.http.forkSession(sid, body); - } - compactSession( - sid: string, - body: CompactSessionRequest = {}, - ): Promise { - return this.http.compactSession(sid, body); - } - undoSession( - sid: string, - body: UndoSessionRequest = { count: 1 }, - ): Promise { - return this.http.undoSession(sid, body); - } - archiveSession(sid: string): Promise<{ archived: true }> { - return this.http.archiveSession(sid); - } - listChildren( - sid: string, - query?: { page_size?: number; before_id?: string; after_id?: string; busy?: boolean }, - ): Promise<{ items: Session[]; has_more: boolean }> { - return this.http.listChildren(sid, query); - } - createChild(sid: string, body: SessionChildCreate = {}): Promise { - return this.http.createChild(sid, body); - } - - // ── Terminals ────────────────────────────────────────────────────────── - listTerminals(sid: string): Promise { - return this.http.listTerminals(sid); - } - createTerminal( - sid: string, - body: CreateTerminalRequest = {}, - ): Promise { - return this.http.createTerminal(sid, body); - } - getTerminal(sid: string, terminalId: string): Promise { - return this.http.getTerminal(sid, terminalId); - } - closeTerminal( - sid: string, - terminalId: string, - ): Promise { - return this.http.closeTerminal(sid, terminalId); - } - - // ── Workspaces + folder picker ────────────────────────────────────────── - listWorkspaces(): Promise<{ items: Workspace[] }> { - return this.http.listWorkspaces(); - } - createWorkspace(body: WorkspaceCreate): Promise { - return this.http.createWorkspace(body); - } - updateWorkspace(workspaceId: string, body: WorkspaceUpdate): Promise { - return this.http.updateWorkspace(workspaceId, body); - } - deleteWorkspace(workspaceId: string): Promise<{ deleted: true }> { - return this.http.deleteWorkspace(workspaceId); - } - fsBrowse(path?: string): Promise { - return this.http.fsBrowse(path); - } - fsHome(): Promise { - return this.http.fsHome(); - } - uploadFile(input: { - name: string; - data: UploadFileData; - mediaType?: string; - expiresInSec?: number; - }): Promise { - return this.http.uploadFile(input); - } - deleteFile(fileId: string): Promise<{ deleted: true }> { - return this.http.deleteFile(fileId); - } - listMessages( - sid: string, - query?: { page_size?: number; before_id?: string; after_id?: string; role?: string }, - ): Promise<{ items: Message[]; has_more: boolean }> { - return this.http.listMessages(sid, query); - } - submitPrompt(sid: string, input: PromptSubmitInput): Promise { - return this.http.submitPrompt(sid, fillPromptDefaults(input)); - } - /** - * Stateful-session submit — sends `body` to `POST /sessions/{sid}/prompts` - * verbatim, with NO default controls injected. Pair with - * `updateSession(sid, {agent_config: {...}})` (or `submitPrompt` with the - * legacy default-filled path) to first establish session state, then - * exercise the "content-only prompt inherits session state" contract. - */ - submitPromptStateful( - sid: string, - body: PromptSubmission, - ): Promise { - return this.http.submitPrompt(sid, body); - } - listPrompts(sid: string): Promise { - return this.http.listPrompts(sid); - } - steerPrompt(sid: string, pid: string): Promise { - return this.http.steerPrompt(sid, pid); - } - steerPrompts(sid: string, promptIds: readonly string[]): Promise { - return this.http.steerPrompts(sid, promptIds); - } - abortPrompt(sid: string, pid: string): Promise { - return this.http.abortPrompt(sid, pid); - } - abortSession(sid: string): Promise { - return this.http.abortSession(sid); - } - resolveApproval( - sid: string, - aid: string, - body: ApprovalResponse, - ): Promise { - return this.http.resolveApproval(sid, aid, body); - } - listPendingApprovals(sid: string): Promise { - return this.http.listPendingApprovals(sid); - } - resolveQuestion( - sid: string, - qid: string, - body: QuestionResponse, - ): Promise { - return this.http.resolveQuestion(sid, qid, body); - } - listPendingQuestions(sid: string): Promise { - return this.http.listPendingQuestions(sid); - } - dismissQuestion( - sid: string, - qid: string, - ): Promise<{ dismissed: true; dismissed_at: string }> { - return this.http.dismissQuestion(sid, qid); - } - - // ── WS lifecycle ──────────────────────────────────────────────────────── - /** - * Open the WS socket, wait for `server_hello`, send `client_hello`, await - * the ack. Returns the server's hello payload (buffer sizes, capabilities, - * etc.). - */ - async connect(): Promise { - if (this._serverHello) return this._serverHello; - const wsUrl = `${this.baseUrl.replace(/^http/, 'ws')}${this.apiPrefix}/ws`; - const ws = new WsClient({ - url: wsUrl, - wsImpl: this._wsImpl, - logger: this._logger, - reportDir: this._reportDir, - }); - this._ws = ws; - await ws.open(); - - const helloFrame = await ws.waitForFrame( - (f) => f.type === 'server_hello', - this._controlAckTimeoutMs, - ); - const helloPayload = helloFrame.payload as ServerHelloMessage['payload']; - this._serverHello = helloPayload; - - const helloId = `hello-${ulid()}`; - const ack = await ws.sendAndAwaitAck( - { - type: 'client_hello', - id: helloId, - payload: { client_id: this.clientId, subscriptions: [] }, - }, - this._controlAckTimeoutMs, - ); - if (ack.code !== 0) { - throw new Error(`client_hello rejected (code=${ack.code}): ${ack.msg ?? 'no message'}`); - } - this._logger('debug', 'ws: handshake complete', { - wsConnectionId: helloPayload.ws_connection_id, - clientId: this.clientId, - }); - return helloPayload; - } - - /** Send `subscribe` and await its ack. Tracks the session for `close()`. */ - async subscribe(sid: string): Promise { - const ws = this._requireWs(); - if (this._subscribed.has(sid)) return; - const id = `sub-${ulid()}`; - const ack = await ws.sendAndAwaitAck( - { type: 'subscribe', id, payload: { session_ids: [sid] } }, - this._controlAckTimeoutMs, - ); - if (ack.code !== 0) { - throw new Error(`subscribe rejected (code=${ack.code}): ${ack.msg ?? 'no message'}`); - } - this._subscribed.add(sid); - } - - /** Send `unsubscribe` and await its ack. */ - async unsubscribe(sid: string): Promise { - const ws = this._requireWs(); - if (!this._subscribed.has(sid)) return; - const id = `unsub-${ulid()}`; - const ack = await ws.sendAndAwaitAck( - { type: 'unsubscribe', id, payload: { session_ids: [sid] } }, - this._controlAckTimeoutMs, - ); - if (ack.code !== 0) { - throw new Error(`unsubscribe rejected (code=${ack.code}): ${ack.msg ?? 'no message'}`); - } - this._subscribed.delete(sid); - } - - /** Close the socket. Idempotent. */ - async close(): Promise { - for (const dispose of this._disposers.splice(0)) { - try { - dispose(); - } catch { - // ignore - } - } - if (this._ws) { - await this._ws.close(); - this._ws = null; - } - this._serverHello = null; - this._subscribed.clear(); - } - - // ── WS observation ────────────────────────────────────────────────────── - /** Subscribe to ALL incoming frames. Returns an unsubscribe handle. */ - onFrame(handler: (frame: AnyFrame) => void): () => void { - return this._requireWs().onFrame(handler); - } - - /** Wait for the next frame satisfying `predicate`. */ - waitForFrame( - predicate: (frame: AnyFrame) => boolean, - opts?: { timeoutMs?: number }, - ): Promise { - return this._requireWs().waitForFrame( - predicate, - opts?.timeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS, - ); - } - - /** Poll `/sessions/{sid}` until its aggregate work flag reaches `busy`. */ - waitForSessionBusy( - sid: string, - busy: boolean, - opts?: { timeoutMs?: number; pollMs?: number }, - ): Promise { - return waitForSessionBusy(this.http, sid, busy, opts); - } - - // ── Terminal WS controls ─────────────────────────────────────────────── - attachTerminal( - sid: string, - terminalId: string, - options: TerminalAttachOptions = {}, - ): Promise { - return this._sendWsControl( - 'terminal_attach', - { - session_id: sid, - terminal_id: terminalId, - since_seq: options.sinceSeq, - }, - options.timeoutMs, - ); - } - - detachTerminal( - sid: string, - terminalId: string, - options: TerminalControlOptions = {}, - ): Promise { - return this._sendWsControl( - 'terminal_detach', - { session_id: sid, terminal_id: terminalId }, - options.timeoutMs, - ); - } - - writeTerminalInput( - sid: string, - terminalId: string, - data: string, - options: TerminalControlOptions = {}, - ): Promise { - return this._sendWsControl( - 'terminal_input', - { session_id: sid, terminal_id: terminalId, data }, - options.timeoutMs, - ); - } - - resizeTerminal( - sid: string, - terminalId: string, - cols: number, - rows: number, - options: TerminalControlOptions = {}, - ): Promise { - return this._sendWsControl( - 'terminal_resize', - { session_id: sid, terminal_id: terminalId, cols, rows }, - options.timeoutMs, - ); - } - - closeTerminalControl( - sid: string, - terminalId: string, - options: TerminalControlOptions = {}, - ): Promise { - return this._sendWsControl( - 'terminal_close', - { session_id: sid, terminal_id: terminalId }, - options.timeoutMs, - ); - } - - // ── Reverse RPC (approval + question) ─────────────────────────────────── - /** - * Install a handler invoked on every `event.approval.requested` frame. - * The handler's return value is POSTed to `/sessions/{sid}/approvals/{aid}`. - * Returns an unsubscribe handle (also auto-disposed by `close()`). - */ - onApprovalRequested( - handler: (req: ApprovalRequest) => Promise | ApprovalResponse, - ): () => void { - const ws = this._requireWs(); - const unsubscribe = installReverseRpcHandler(ws, { - requestEventType: 'event.approval.requested', - idField: 'approval_id', - buildPath: (sid, aid) => `/sessions/${sid}/approvals/${aid}`, - handler, - postResolve: (sid, aid, body) => this.http.resolveApproval(sid, aid, body), - logger: this._logger, - }); - this._disposers.push(unsubscribe); - return () => { - const idx = this._disposers.indexOf(unsubscribe); - if (idx >= 0) this._disposers.splice(idx, 1); - unsubscribe(); - }; - } - - /** - * Install a handler invoked on every `event.question.requested` frame. - * Returns an unsubscribe handle (also auto-disposed by `close()`). - */ - onQuestionAsked( - handler: (req: QuestionRequest) => Promise | QuestionResponse, - ): () => void { - const ws = this._requireWs(); - const unsubscribe = installReverseRpcHandler(ws, { - requestEventType: 'event.question.requested', - idField: 'question_id', - buildPath: (sid, qid) => `/sessions/${sid}/questions/${qid}`, - handler, - postResolve: (sid, qid, body) => this.http.resolveQuestion(sid, qid, body), - logger: this._logger, - }); - this._disposers.push(unsubscribe); - return () => { - const idx = this._disposers.indexOf(unsubscribe); - if (idx >= 0) this._disposers.splice(idx, 1); - unsubscribe(); - }; - } - - // ── High-level convenience ────────────────────────────────────────────── - /** - * Submit a prompt and wait for its terminal event. `waitFor` defaults to - * the synthesized `prompt.completed` event (broadcast after `turn.ended` - * lands for the same prompt). Returns `prompt_id` and the matching frame. - */ - async submitAndWait( - sid: string, - input: PromptSubmitInput, - opts: SubmitAndWaitOptions = {}, - ): Promise<{ prompt_id: string; user_message_id: string; finalFrame: AnyFrame }> { - const ws = this._requireWs(); - const waitFor = opts.waitFor ?? 'prompt.completed'; - const timeoutMs = opts.timeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS; - - // POST the prompt FIRST — without `prompt_id` we have nothing to match on. - // The WS layer queues every frame from the moment we open, so any events - // that arrive between this POST and the `waitForFrame` below are still - // there to be matched (they're drained from the queue, not dropped). - const submit = await this.http.submitPrompt(sid, fillPromptDefaults(input)); - - const finalFrame = await ws.waitForFrame((f) => { - if (f.type !== waitFor) return false; - const payload = (f.payload as { promptId?: string; prompt_id?: string } | undefined) ?? {}; - const pid = payload.promptId ?? payload.prompt_id; - return pid === submit.prompt_id; - }, timeoutMs); - - return { prompt_id: submit.prompt_id, user_message_id: submit.user_message_id, finalFrame }; - } - - /** - * Stateful-session companion to `submitAndWait` — POSTs `body` verbatim - * (NO default controls injected), then waits for the terminal event for - * the resulting `prompt_id`. Use after `updateSession(sid, {agent_config: - * {...}})` to verify the session's shadow drives the next prompt without - * the body needing to redeclare any controls. - */ - async submitAndWaitStateful( - sid: string, - body: PromptSubmission, - opts: SubmitAndWaitOptions = {}, - ): Promise<{ prompt_id: string; user_message_id: string; finalFrame: AnyFrame }> { - const ws = this._requireWs(); - const waitFor = opts.waitFor ?? 'prompt.completed'; - const timeoutMs = opts.timeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS; - const submit = await this.http.submitPrompt(sid, body); - const finalFrame = await ws.waitForFrame((f) => { - if (f.type !== waitFor) return false; - const payload = (f.payload as { promptId?: string; prompt_id?: string } | undefined) ?? {}; - const pid = payload.promptId ?? payload.prompt_id; - return pid === submit.prompt_id; - }, timeoutMs); - return { prompt_id: submit.prompt_id, user_message_id: submit.user_message_id, finalFrame }; - } - - // ── internals ─────────────────────────────────────────────────────────── - private _requireWs(): WsClient { - if (!this._ws) { - throw new Error('ws not connected — call `await client.connect()` first'); - } - return this._ws; - } - - private async _sendWsControl( - type: string, - payload: Record, - timeoutMs?: number, - ): Promise { - const id = `${type}-${ulid()}`; - const ack = await this._requireWs().sendAndAwaitAck( - { type, id, payload }, - timeoutMs ?? this._controlAckTimeoutMs, - ); - if (ack.code !== 0) { - throw new Error(`${type} rejected (code=${ack.code ?? 'unknown'}): ${ack.msg ?? 'no message'}`); - } - return (ack.payload ?? {}) as T; - } -} - -function noopLogger(): void { - // intentionally blank -} diff --git a/packages/klient/test/e2e/harness/envelope.ts b/packages/klient/test/e2e/harness/envelope.ts deleted file mode 100644 index 489b01a9599..00000000000 --- a/packages/klient/test/e2e/harness/envelope.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * REST envelope helpers — unwrap `{ code, msg, data, request_id }` into either - * a typed `data` or an `EnvelopeError` thrown by the caller. - * - * Mirrors `packages/kap-server/src/protocol/envelope.ts` so the server's wire - * shape and this client's parsing stay in lockstep. - */ -import { type Envelope } from '@moonshot-ai/kap-server/protocol/envelope'; -import { ErrorCode } from '@moonshot-ai/kap-server/protocol/error-codes'; - -/** - * Thrown when an HTTP call lands but `envelope.code !== 0`. - * - * `data` is preserved separately because several server endpoints return - * non-zero envelopes with a non-null `data` payload (REST §3.6 idempotent - * re-resolve: `code: 40902 + data: { resolved: false }`). - */ -export class EnvelopeError extends Error { - readonly code: number; - readonly reason: string; - readonly requestId: string; - readonly data: T | null; - - constructor(envelope: Envelope) { - const reason = - Object.entries(ErrorCode) - .find(([, value]) => value === envelope.code)?.[0] - ?.toLowerCase() - .replaceAll('_', '.') ?? 'unknown'; - super(`server returned code=${envelope.code} (${reason}): ${envelope.msg}`); - this.name = 'EnvelopeError'; - this.code = envelope.code; - this.reason = reason; - this.requestId = envelope.request_id; - this.data = envelope.data; - } -} - -/** - * Unwrap a parsed envelope. On `code === 0` returns `data` (which may be - * `null` — callers asking for a non-nullable type should narrow). - */ -export function unwrap(envelope: Envelope): T { - if (envelope.code !== 0) throw new EnvelopeError(envelope); - if (envelope.data === null) { - // `code: 0 + data: null` is reserved for "no body" success envelopes; the - // current server surface always returns a non-null data on success, so - // surface this as a hard error rather than silently returning `null`. - throw new EnvelopeError({ ...envelope, code: 50001, msg: 'success envelope had null data' }); - } - return envelope.data; -} diff --git a/packages/klient/test/e2e/harness/http.ts b/packages/klient/test/e2e/harness/http.ts deleted file mode 100644 index 37cde367422..00000000000 --- a/packages/klient/test/e2e/harness/http.ts +++ /dev/null @@ -1,501 +0,0 @@ -/** - * HTTP layer for `DaemonClient` — typed wrappers around fetch + envelope - * unwrap. All paths concatenate `baseUrl + apiPrefix + route`. - */ -import type { - FsBrowseResponse, - FsHomeResponse, -} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; -import type { AuthSummary } from '@moonshot-ai/agent-core-v2/app/authLegacy/authLegacy'; -import type { FileMeta } from '@moonshot-ai/agent-core-v2/app/file/fileService'; -import type { UpdateSessionProfileRequest as SessionUpdate } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol'; -import type { - ProviderCatalogItem, - SetDefaultModelResponse, -} from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; -import type { Terminal } from '@moonshot-ai/agent-core-v2/os/interface/terminal'; -import type { ApprovalResponse } from '@moonshot-ai/kap-server/protocol/approval'; -import type { Envelope } from '@moonshot-ai/kap-server/protocol/envelope'; -import type { Message } from '@moonshot-ai/kap-server/protocol/message'; -import type { QuestionResponse } from '@moonshot-ai/kap-server/protocol/question'; -import type { - ApprovalResolveResult, - ListPendingApprovalsResponse, -} from '@moonshot-ai/kap-server/protocol/rest-approval'; -import type { - ListModelsResponse, - ListProvidersResponse, -} from '@moonshot-ai/kap-server/protocol/rest-modelCatalog'; -import type { - PromptAbortResponse, - PromptListResponse, - PromptSubmission, - PromptSteerResult, - PromptSubmitResult, -} from '@moonshot-ai/kap-server/protocol/rest-prompt'; -import type { - ListPendingQuestionsResponse, - QuestionResolveResult, -} from '@moonshot-ai/kap-server/protocol/rest-question'; -import type { - CompactSessionRequest, - CompactSessionResponse, - ForkSessionRequest, - SessionAbortResponse, - UndoSessionRequest, - UndoSessionResponse, -} from '@moonshot-ai/kap-server/protocol/rest-session'; -import type { - CloseTerminalResponse, - CreateTerminalRequest, - ListTerminalsResponse, -} from '@moonshot-ai/kap-server/protocol/rest-terminal'; -import type { - Session, - SessionChildCreate, - SessionCreate, -} from '@moonshot-ai/kap-server/protocol/session'; -import type { - Workspace, - WorkspaceCreate, - WorkspaceUpdate, -} from '@moonshot-ai/kap-server/protocol/workspace'; - -import { unwrap } from './envelope.js'; -import { fetchWithReport, recordReportEvent } from './report.js'; - -export interface HttpClientOptions { - baseUrl: string; - apiPrefix: string; - fetchImpl: typeof fetch; - reportDir?: string; - /** Optional bearer token — sent as `Authorization: Bearer ` when set. */ - token?: string; -} - -type UploadFileData = Blob | ArrayBuffer | Uint8Array | string; - -export class HttpClient { - constructor(private readonly opts: HttpClientOptions) {} - - private url(path: string): string { - return `${this.opts.baseUrl}${this.opts.apiPrefix}${path}`; - } - - private async request( - method: string, - path: string, - body: unknown, - ): Promise { - const startedAt = Date.now(); - const headers: Record = { accept: 'application/json' }; - if (this.opts.token !== undefined) { - headers['authorization'] = `Bearer ${this.opts.token}`; - } - let init: RequestInit; - if (body !== undefined) { - headers['content-type'] = 'application/json'; - init = { method, headers, body: JSON.stringify(body) }; - } else { - init = { method, headers }; - } - const url = this.url(path); - let res: Response; - let text = ''; - try { - res = await this.opts.fetchImpl(url, init); - text = await res.text(); - } catch (error) { - recordReportEvent( - { - kind: 'http', - method, - path, - url, - durationMs: Date.now() - startedAt, - request: requestForReport(body), - error: errorForReport(error), - }, - { reportDir: this.opts.reportDir }, - ); - throw error; - } - let envelope: Envelope; - try { - envelope = JSON.parse(text) as Envelope; - } catch (error) { - recordReportEvent( - { - kind: 'http', - method, - path, - url, - status: res.status, - durationMs: Date.now() - startedAt, - request: requestForReport(body), - response: { raw: text.slice(0, 2_000) }, - error: errorForReport(error), - }, - { reportDir: this.opts.reportDir }, - ); - throw new Error( - `server ${method} ${path} returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, - { cause: error }, - ); - } - recordReportEvent( - { - kind: 'http', - method, - path, - url, - status: res.status, - durationMs: Date.now() - startedAt, - request: requestForReport(body), - response: { envelope }, - }, - { reportDir: this.opts.reportDir }, - ); - return unwrap(envelope); - } - - private async formRequest( - method: 'POST', - path: string, - body: FormData, - ): Promise { - const url = this.url(path); - const res = await fetchWithReport( - url, - { - method, - headers: { accept: 'application/json' }, - body, - }, - { - fetchImpl: this.opts.fetchImpl, - reportDir: this.opts.reportDir, - path, - }, - ); - const text = await res.text(); - let envelope: Envelope; - try { - envelope = JSON.parse(text) as Envelope; - } catch (error) { - throw new Error( - `server ${method} ${path} returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, - { cause: error }, - ); - } - return unwrap(envelope); - } - - // ── Probes + model catalog ───────────────────────────────────────────── - getAuth(): Promise { - return this.request('GET', '/auth', undefined); - } - listModels(): Promise { - return this.request('GET', '/models', undefined); - } - setDefaultModel(modelId: string): Promise { - return this.request( - 'POST', - `/models/${encodeURIComponent(modelId)}:set_default`, - {}, - ); - } - listProviders(): Promise { - return this.request('GET', '/providers', undefined); - } - getProvider(providerId: string): Promise { - return this.request( - 'GET', - `/providers/${encodeURIComponent(providerId)}`, - undefined, - ); - } - - // ── Sessions ──────────────────────────────────────────────────────────── - createSession(body: SessionCreate): Promise { - return this.request('POST', '/sessions', body); - } - getSession(sid: string): Promise { - return this.request('GET', `/sessions/${encodeURIComponent(sid)}`, undefined); - } - listSessions(query?: { - page_size?: number; - before_id?: string; - after_id?: string; - workspace_id?: string; - }): Promise<{ items: Session[]; has_more: boolean }> { - return this.request('GET', `/sessions${qs(query)}`, undefined); - } - updateSession(sid: string, body: SessionUpdate): Promise { - // Daemon canonical route: `POST /v1/sessions/{sid}/profile` (REST.md §3.3). - // Earlier scaffolding spoke `PATCH /v1/sessions/{sid}`, which the server - // never wired — keep the helper name (used by existing fixtures) and just - // dispatch to the right URL. - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sid)}/profile`, - body, - ); - } - forkSession(sid: string, body: ForkSessionRequest = {}): Promise { - return this.request('POST', `/sessions/${encodeURIComponent(sid)}:fork`, body); - } - compactSession( - sid: string, - body: CompactSessionRequest = {}, - ): Promise { - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sid)}:compact`, - body, - ); - } - undoSession( - sid: string, - body: UndoSessionRequest = { count: 1 }, - ): Promise { - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sid)}:undo`, - body, - ); - } - archiveSession(sid: string): Promise<{ archived: true }> { - return this.request('POST', `/sessions/${encodeURIComponent(sid)}:archive`, {}); - } - listChildren( - sid: string, - query?: { page_size?: number; before_id?: string; after_id?: string; busy?: boolean }, - ): Promise<{ items: Session[]; has_more: boolean }> { - return this.request( - 'GET', - `/sessions/${encodeURIComponent(sid)}/children${qs(query)}`, - undefined, - ); - } - createChild(sid: string, body: SessionChildCreate = {}): Promise { - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sid)}/children`, - body, - ); - } - - // ── Terminals ────────────────────────────────────────────────────────── - listTerminals(sid: string): Promise { - return this.request( - 'GET', - `/sessions/${encodeURIComponent(sid)}/terminals`, - undefined, - ); - } - createTerminal( - sid: string, - body: CreateTerminalRequest = {}, - ): Promise { - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sid)}/terminals`, - body, - ); - } - getTerminal(sid: string, terminalId: string): Promise { - return this.request( - 'GET', - `/sessions/${encodeURIComponent(sid)}/terminals/${encodeURIComponent(terminalId)}`, - undefined, - ); - } - closeTerminal( - sid: string, - terminalId: string, - ): Promise { - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sid)}/terminals/${encodeURIComponent(terminalId)}:close`, - {}, - ); - } - - // ── Workspaces ────────────────────────────────────────────────────────── - listWorkspaces(): Promise<{ items: Workspace[] }> { - return this.request('GET', '/workspaces', undefined); - } - createWorkspace(body: WorkspaceCreate): Promise { - return this.request('POST', '/workspaces', body); - } - updateWorkspace(workspaceId: string, body: WorkspaceUpdate): Promise { - return this.request( - 'PATCH', - `/workspaces/${encodeURIComponent(workspaceId)}`, - body, - ); - } - deleteWorkspace(workspaceId: string): Promise<{ deleted: true }> { - return this.request( - 'DELETE', - `/workspaces/${encodeURIComponent(workspaceId)}`, - undefined, - ); - } - - // ── Folder picker (fs:browse + fs:home) ───────────────────────────────── - fsBrowse(path?: string): Promise { - return this.request('GET', `/fs:browse${qs({ path })}`, undefined); - } - fsHome(): Promise { - return this.request('GET', '/fs:home', undefined); - } - - // ── Uploads ───────────────────────────────────────────────────────────── - uploadFile(input: { - name: string; - data: UploadFileData; - mediaType?: string; - expiresInSec?: number; - }): Promise { - const form = new FormData(); - form.append('name', input.name); - if (input.expiresInSec !== undefined) { - form.append('expires_in_sec', String(input.expiresInSec)); - } - form.append('file', blobFromInput(input), input.name); - return this.formRequest('POST', '/files', form); - } - deleteFile(fileId: string): Promise<{ deleted: true }> { - return this.request('DELETE', `/files/${encodeURIComponent(fileId)}`, undefined); - } - - // ── Messages ──────────────────────────────────────────────────────────── - listMessages( - sid: string, - query?: { page_size?: number; before_id?: string; after_id?: string; role?: string }, - ): Promise<{ items: Message[]; has_more: boolean }> { - return this.request('GET', `/sessions/${encodeURIComponent(sid)}/messages${qs(query)}`, undefined); - } - - // ── Prompts ───────────────────────────────────────────────────────────── - listPrompts(sid: string): Promise { - return this.request('GET', `/sessions/${encodeURIComponent(sid)}/prompts`, undefined); - } - submitPrompt(sid: string, body: PromptSubmission): Promise { - return this.request('POST', `/sessions/${encodeURIComponent(sid)}/prompts`, body); - } - steerPrompt(sid: string, pid: string): Promise { - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sid)}/prompts/${encodeURIComponent(pid)}:steer`, - {}, - ); - } - steerPrompts(sid: string, promptIds: readonly string[]): Promise { - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sid)}/prompts:steer`, - { prompt_ids: [...promptIds] }, - ); - } - abortPrompt(sid: string, pid: string): Promise { - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sid)}/prompts/${encodeURIComponent(pid)}:abort`, - {}, - ); - } - abortSession(sid: string): Promise { - return this.request('POST', `/sessions/${encodeURIComponent(sid)}:abort`, {}); - } - - // ── Approvals / Questions (reverse-RPC resolves) ──────────────────────── - resolveApproval( - sid: string, - aid: string, - body: ApprovalResponse, - ): Promise { - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sid)}/approvals/${encodeURIComponent(aid)}`, - body, - ); - } - listPendingApprovals(sid: string): Promise { - return this.request( - 'GET', - `/sessions/${encodeURIComponent(sid)}/approvals?status=pending`, - undefined, - ); - } - resolveQuestion( - sid: string, - qid: string, - body: QuestionResponse, - ): Promise { - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sid)}/questions/${encodeURIComponent(qid)}`, - body, - ); - } - listPendingQuestions(sid: string): Promise { - return this.request( - 'GET', - `/sessions/${encodeURIComponent(sid)}/questions?status=pending`, - undefined, - ); - } - dismissQuestion( - sid: string, - qid: string, - ): Promise<{ dismissed: true; dismissed_at: string }> { - return this.request( - 'POST', - `/sessions/${encodeURIComponent(sid)}/questions/${encodeURIComponent(qid)}:dismiss`, - {}, - ); - } -} - -function requestForReport(body: unknown): { body?: unknown } { - return body === undefined ? {} : { body }; -} - -function errorForReport(error: unknown): unknown { - if (error instanceof Error) { - return { - name: error.name, - message: error.message, - }; - } - return error; -} - -function qs(query: Record | undefined): string { - if (!query) return ''; - const parts: string[] = []; - for (const [k, v] of Object.entries(query)) { - if (v === undefined) continue; - parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(serializedQueryValue(v))}`); - } - return parts.length > 0 ? `?${parts.join('&')}` : ''; -} - -function serializedQueryValue(value: string | number | boolean): string { - if (typeof value === 'string') return value; - if (typeof value === 'number') return value.toString(); - return value ? 'true' : 'false'; -} - -function blobFromInput(input: { - data: UploadFileData; - mediaType?: string; -}): Blob { - if (input.data instanceof Blob) return input.data; - return new Blob([input.data], { - type: input.mediaType ?? 'application/octet-stream', - }); -} diff --git a/packages/klient/test/e2e/harness/index.ts b/packages/klient/test/e2e/harness/index.ts deleted file mode 100644 index b9a9094033c..00000000000 --- a/packages/klient/test/e2e/harness/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * `@moonshot-ai/server-e2e` — wire-level test client for the kimi-code server. - * - * Use this package from scenarios (`scenarios/*.ts`) and vitest e2e tests - * to drive a real server process at `http://127.0.0.1:58627` (or any baseUrl - * you pass via `DaemonClientOptions.baseUrl`). - * - * Public surface: - * - `DaemonClient` — main facade (HTTP + WS lifecycle) - * - `HttpClient` — REST helpers only (typed, envelope-unwrap) - * - `WsClient` — raw WS wrapper (queue + waiters + acks) - * - `EnvelopeError` — thrown on `code !== 0` - * - `fetchWithReport` / `writeHtmlReport` — report capture + rendering - * - `installReverseRpcHandler` — uniform helper for approval/question - * - `waitForFrame` / `waitForSessionBusy` — standalone wait helpers - * - * Wire DTO types are NOT re-exported here — scenarios import them from - * `@moonshot-ai/kap-server/protocol/*` or agent-core-v2 directly. - */ -export { DaemonClient } from './client.js'; -export type { - DaemonClientOptions, - SubmitAndWaitOptions, - TerminalAttachOptions, - TerminalAttachResult, - TerminalCloseResult, - TerminalControlOptions, - TerminalDetachResult, - TerminalInputResult, - TerminalResizeResult, -} from './client.js'; - -export { HttpClient } from './http.js'; -export type { HttpClientOptions } from './http.js'; - -export { WsClient } from './ws.js'; -export type { AnyFrame, WsClientOptions } from './ws.js'; - -export { EnvelopeError, unwrap } from './envelope.js'; - -export { - defaultReportDir, - fetchWithReport, - getActiveReportCase, - readReportEvents, - recordReportEvent, - resetReportDir, - setActiveReportCase, - writeHtmlReport, -} from './report.js'; -export type { - FetchWithReportOptions, - HtmlReportOptions, - HttpReportEvent, - LogReportEvent, - ReportEvent, - ReportEventBase, - ReportEventKind, - ReportOptions, - StoredReportEvent, - TestResultReportEvent, - WsDirection, - WsReportEvent, -} from './report.js'; - -export { installReverseRpcHandler } from './reverse-rpc.js'; -export type { ReverseRpcOptions } from './reverse-rpc.js'; - -export { DEFAULT_FRAME_TIMEOUT_MS, waitForFrame, waitForSessionBusy } from './wait.js'; - diff --git a/packages/klient/test/e2e/harness/report.ts b/packages/klient/test/e2e/harness/report.ts deleted file mode 100644 index c522e2b964a..00000000000 --- a/packages/klient/test/e2e/harness/report.ts +++ /dev/null @@ -1,662 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import { - appendFileSync, - existsSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { join, resolve } from 'node:path'; - -export type ReportEventKind = 'log' | 'http' | 'ws' | 'test-result'; -export type WsDirection = 'in' | 'out' | 'lifecycle'; - -export interface ReportEventBase { - kind: ReportEventKind; - caseName?: string; - at?: string; -} - -export interface LogReportEvent extends ReportEventBase { - kind: 'log'; - label: string; - value?: unknown; -} - -export interface HttpReportEvent extends ReportEventBase { - kind: 'http'; - method: string; - path: string; - url?: string; - status?: number; - durationMs?: number; - request?: unknown; - response?: unknown; - error?: unknown; -} - -export interface WsReportEvent extends ReportEventBase { - kind: 'ws'; - direction: WsDirection; - url?: string; - frame?: unknown; - message?: string; - error?: unknown; -} - -export interface TestResultReportEvent extends ReportEventBase { - kind: 'test-result'; - state: 'passed' | 'failed' | 'skipped'; - durationMs?: number; - error?: unknown; -} - -export type ReportEvent = - | LogReportEvent - | HttpReportEvent - | WsReportEvent - | TestResultReportEvent; - -export interface StoredReportEvent extends ReportEventBase { - kind: ReportEventKind; - pid: number; - ordinal: number; - label?: string; - value?: unknown; - method?: string; - path?: string; - url?: string; - status?: number; - durationMs?: number; - request?: unknown; - response?: unknown; - direction?: WsDirection; - frame?: unknown; - message?: string; - state?: 'passed' | 'failed' | 'skipped'; - error?: unknown; -} - -export interface ReportOptions { - reportDir?: string; -} - -export interface HtmlReportOptions extends ReportOptions { - title?: string; -} - -export interface FetchWithReportOptions extends ReportOptions { - fetchImpl?: typeof fetch; - path?: string; -} - -let activeCaseName: string | undefined; -const activeCaseStorage = new AsyncLocalStorage(); -let ordinal = 0; - -export function setActiveReportCase(caseName: string): void { - activeCaseName = caseName; - activeCaseStorage.enterWith(caseName); -} - -export function getActiveReportCase(): string | undefined { - return activeCaseStorage.getStore() ?? activeCaseName; -} - -export function resetReportDir(reportDir = defaultReportDir()): void { - rmSync(reportDir, { recursive: true, force: true }); - mkdirSync(reportDir, { recursive: true }); - writeFileSync(join(reportDir, '.gitignore'), '*\n!.gitignore\n'); -} - -export function recordReportEvent(event: ReportEvent, options?: ReportOptions): void { - const reportDir = options?.reportDir ?? defaultReportDir(); - mkdirSync(reportDir, { recursive: true }); - const stored = normalizeEvent(event); - appendFileSync(reportEventsPath(reportDir), `${JSON.stringify(stored)}\n`); -} - -export function readReportEvents(reportDir = defaultReportDir()): StoredReportEvent[] { - if (!existsSync(reportDir)) return []; - const files = readdirSync(reportDir) - .filter((file) => file.startsWith('events-') && file.endsWith('.jsonl')) - .toSorted(); - const events: StoredReportEvent[] = []; - for (const file of files) { - const text = readFileSync(join(reportDir, file), 'utf8'); - for (const line of text.split('\n')) { - if (line.trim().length === 0) continue; - events.push(JSON.parse(line) as StoredReportEvent); - } - } - return events.toSorted((a, b) => { - const byTime = Date.parse(a.at ?? '') - Date.parse(b.at ?? ''); - if (byTime !== 0) return byTime; - if (a.pid !== b.pid) return a.pid - b.pid; - return a.ordinal - b.ordinal; - }); -} - -export function writeHtmlReport(options?: HtmlReportOptions): string { - const reportDir = options?.reportDir ?? defaultReportDir(); - mkdirSync(reportDir, { recursive: true }); - const title = options?.title ?? 'server-e2e report'; - const events = readReportEvents(reportDir); - const htmlPath = join(reportDir, 'index.html'); - writeFileSync(htmlPath, renderHtml(title, events)); - return htmlPath; -} - -export async function fetchWithReport( - input: Parameters[0], - init?: Parameters[1], - options?: FetchWithReportOptions, -): Promise { - const fetchImpl = options?.fetchImpl ?? fetch; - const method = fetchMethod(input, init); - const url = fetchUrl(input); - const path = options?.path ?? pathFromUrl(url); - const startedAt = Date.now(); - let response: Response; - try { - response = await fetchImpl(input, init); - } catch (error) { - recordReportEvent( - { - kind: 'http', - method, - path, - url, - durationMs: Date.now() - startedAt, - request: requestForFetchReport(input, init), - error: errorForReport(error), - }, - { reportDir: options?.reportDir }, - ); - throw error; - } - - const text = await response.clone().text(); - recordReportEvent( - { - kind: 'http', - method, - path, - url, - status: response.status, - durationMs: Date.now() - startedAt, - request: requestForFetchReport(input, init), - response: responseForReport(text), - }, - { reportDir: options?.reportDir }, - ); - return response; -} - -export function defaultReportDir(): string { - return resolve(process.env['KIMI_SERVER_E2E_REPORT_DIR'] ?? join(process.cwd(), 'reports', 'latest')); -} - -function normalizeEvent(event: ReportEvent): StoredReportEvent { - const stored = event as StoredReportEvent; - return { - ...stored, - at: event.at ?? new Date().toISOString(), - caseName: event.caseName ?? getActiveReportCase() ?? process.env['KIMI_SERVER_E2E_CASE_NAME'] ?? 'unassigned', - pid: process.pid, - ordinal: ordinal++, - }; -} - -function reportEventsPath(reportDir: string): string { - return join(reportDir, `events-${process.pid}.jsonl`); -} - -function renderHtml(title: string, events: StoredReportEvent[]): string { - const cases = renderCases(groupByCase(events)); - return ` - - - - - ${escapeHtml(title)} - - - -

- -
-
-
- Step - Client -> Server - - Server -> Client -
-
- ${cases.map(renderCase).join('\n')} -
-
- -
-
- - - -`; -} - -interface RenderCase { - id: string; - name: string; - events: RenderEvent[]; -} - -interface RenderEvent { - id: string; - caseId: string; - stepIndex: number; - event: StoredReportEvent; -} - -function groupByCase(events: StoredReportEvent[]): Map { - const cases = new Map(); - for (const event of events) { - const caseName = event.caseName ?? 'unassigned'; - const group = cases.get(caseName); - if (group) { - group.push(event); - } else { - cases.set(caseName, [event]); - } - } - return cases; -} - -function renderCases(cases: Map): RenderCase[] { - let eventIndex = 0; - return [...cases.entries()].map(([name, events], caseIndex) => { - const id = `case-${caseIndex}`; - return { - id, - name, - events: events.map((event, stepIndex) => ({ - id: `event-${eventIndex++}`, - caseId: id, - stepIndex: stepIndex + 1, - event, - })), - }; - }); -} - -function renderCaseLink(testCase: RenderCase): string { - return ` - ${escapeHtml(testCase.name)} - ${testCase.events.length} -`; -} - -function renderCase(testCase: RenderCase): string { - return `
-

${escapeHtml(testCase.name)}

-
    - ${testCase.events.map(renderEvent).join('\n')} -
-
`; -} - -function renderEvent(rendered: RenderEvent): string { - const left = eventLaneContent(rendered.event, 'left'); - const right = eventLaneContent(rendered.event, 'right'); - const center = lifecycleLaneContent(rendered.event); - const rowClass = rendered.event.kind === 'ws' && rendered.event.direction === 'lifecycle' - ? `${rendered.event.kind} lifecycle` - : rendered.event.kind; - return `
  • -
    #${rendered.stepIndex}
    -
    ${left}
    -
    -
    ${right}
    - ${center} -
  • `; -} - -function renderDetailCase(testCase: RenderCase): string { - return `
    -

    ${escapeHtml(testCase.name)}

    - ${testCase.events.map(renderDetailCard).join('\n')} -
    `; -} - -function renderDetailCard(rendered: RenderEvent): string { - const detail = eventDetail(rendered.event); - return `
    -
    - ${escapeHtml(rendered.event.kind)} - ${escapeHtml(eventSummary(rendered.event))} -
    -
    ${escapeHtml(JSON.stringify(detail, null, 2))}
    -
    `; -} - -function eventLaneContent(event: StoredReportEvent, lane: 'left' | 'right'): string { - const content = lane === 'left' ? leftLaneSummary(event) : rightLaneSummary(event); - if (!content) return ''; - return ``; -} - -function lifecycleLaneContent(event: StoredReportEvent): string { - if (event.kind !== 'ws' || event.direction !== 'lifecycle') return ''; - return ``; -} - -function leftLaneSummary(event: StoredReportEvent): { title: string } | undefined { - if (event.kind === 'http') { - return { - title: `${event.method ?? 'HTTP'} ${event.path ?? event.url ?? ''}`.trim(), - }; - } - if (event.kind === 'ws' && event.direction === 'out') { - return { title: eventSummary(event) }; - } - if (event.kind === 'log') { - return { title: event.label ?? 'log' }; - } - return undefined; -} - -function rightLaneSummary(event: StoredReportEvent): { title: string } | undefined { - if (event.kind === 'http') { - return { - title: event.status === undefined ? 'HTTP response' : `HTTP ${event.status}`, - }; - } - if (event.kind === 'ws' && event.direction === 'in') { - return { title: eventSummary(event) }; - } - if (event.kind === 'test-result') { - return { title: eventSummary(event) }; - } - return undefined; -} - -function eventSummary(event: StoredReportEvent): string { - if (event.kind === 'http') { - return `${event.method ?? 'HTTP'} ${event.path ?? event.url ?? ''}`.trim(); - } - if (event.kind === 'ws') { - const label = frameType(event.frame) ?? event.message ?? 'frame'; - if (event.direction === 'lifecycle') return `WS ${label}`; - const arrow = event.direction === 'out' ? '->' : '<-'; - return `WS ${arrow} ${label}`; - } - if (event.kind === 'test-result') { - return `test ${event.state ?? 'unknown'}`; - } - return event.label ?? 'log'; -} - -function eventDetail(event: StoredReportEvent): Record { - const { pid: _pid, ordinal: _ordinal, ...detail } = event; - return detail; -} - -function frameType(frame: unknown): string | undefined { - if (!frame || typeof frame !== 'object') return undefined; - const value = (frame as { type?: unknown }).type; - return typeof value === 'string' ? value : undefined; -} - -function escapeHtml(value: unknown): string { - return String(value) - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -function fetchMethod(input: Parameters[0], init: Parameters[1]): string { - if (init?.method) return init.method.toUpperCase(); - if (input instanceof Request) return input.method.toUpperCase(); - return 'GET'; -} - -function fetchUrl(input: Parameters[0]): string { - if (typeof input === 'string') return input; - if (input instanceof URL) return input.toString(); - return input.url; -} - -function pathFromUrl(url: string): string { - try { - const parsed = new URL(url); - return `${parsed.pathname}${parsed.search}`; - } catch { - return url; - } -} - -function requestForFetchReport( - input: Parameters[0], - init: Parameters[1], -): { body?: unknown } { - if (init?.body !== undefined && init.body !== null) { - return { body: parseBodyForReport(init.body) }; - } - if (input instanceof Request) return {}; - return {}; -} - -function parseBodyForReport(body: NonNullable): unknown { - if (typeof body === 'string') { - try { - return JSON.parse(body) as unknown; - } catch { - return body; - } - } - if (body instanceof URLSearchParams) { - return body.toString(); - } - if (body instanceof FormData) { - return '[FormData]'; - } - if (body instanceof Blob) { - return `[Blob ${body.type || 'application/octet-stream'} ${body.size} bytes]`; - } - if (body instanceof ArrayBuffer) { - return `[ArrayBuffer ${body.byteLength} bytes]`; - } - if (ArrayBuffer.isView(body)) { - return `[${body.constructor.name} ${body.byteLength} bytes]`; - } - return '[ReadableStream]'; -} - -function responseForReport(text: string): { envelope?: unknown; raw?: string } { - try { - return { envelope: JSON.parse(text) as unknown }; - } catch { - return { raw: text.slice(0, 2_000) }; - } -} - -function errorForReport(error: unknown): unknown { - if (error instanceof Error) { - return { - name: error.name, - message: error.message, - }; - } - return error; -} diff --git a/packages/klient/test/e2e/harness/reverse-rpc.ts b/packages/klient/test/e2e/harness/reverse-rpc.ts deleted file mode 100644 index 25779323ab3..00000000000 --- a/packages/klient/test/e2e/harness/reverse-rpc.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Reverse-RPC handler installer — uniform pattern shared by approval and - * question. The two flows are structurally identical: - * - * 1. WS broadcasts `event.{kind}.requested` with the request payload at - * the top level of `envelope.payload`. - * 2. Test installs `onXxxRequested(handler)`. - * 3. On each request frame, we call `handler(request)` → POST the - * decision/answer to `/sessions/{sid}/{kind}s/{id}`. - * - * Errors from the user handler (or the REST POST) are swallowed into a - * logger.warn — failing reverse-RPC silently means the server will time out - * the approval/question after 60s, which the scenario will observe as a - * timeout in `waitForFrame`. Surfacing those errors here would break the - * "framework auto-responds" contract. - */ -import type { AnyFrame, WsClient } from './ws.js'; - -export interface ReverseRpcOptions { - requestEventType: string; - idField: keyof Req & string; - /** REST path under the server API prefix. */ - buildPath: (sessionId: string, id: string) => string; - handler: (req: Req) => Promise | Res; - /** POST helper bound to the right path. */ - postResolve: (sessionId: string, id: string, body: Res) => Promise; - logger: (level: 'info' | 'warn' | 'error' | 'debug', msg: string, meta?: unknown) => void; -} - -/** - * Subscribe to `requestEventType` frames on `ws` and POST the user-supplied - * response. Returns an unsubscribe handle. - */ -export function installReverseRpcHandler( - ws: WsClient, - opts: ReverseRpcOptions, -): () => void { - const unsubscribe = ws.onFrame((frame: AnyFrame) => { - if (frame.type !== opts.requestEventType) return; - const payload = frame.payload as Req | undefined; - if (!payload) return; - const sessionId = (payload as { session_id?: string }).session_id; - const id = (payload as Record)[opts.idField] as string | undefined; - if (!sessionId || !id) { - opts.logger('warn', `reverse-rpc: ${opts.requestEventType} missing session_id/${opts.idField}`, { - payload, - }); - return; - } - // Fire-and-forget: the WS handler is sync; we kick off the resolve and - // log async failures. - Promise.resolve() - .then(async () => { - const response = await opts.handler(payload); - await opts.postResolve(sessionId, id, response); - }) - .catch((err) => { - opts.logger('warn', `reverse-rpc: ${opts.requestEventType} resolve failed`, { - err: String(err), - sessionId, - id, - }); - }); - }); - return unsubscribe; -} diff --git a/packages/klient/test/e2e/harness/wait.ts b/packages/klient/test/e2e/harness/wait.ts deleted file mode 100644 index 9a8ac89aa40..00000000000 --- a/packages/klient/test/e2e/harness/wait.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Higher-level wait helpers built on top of `WsClient.waitForFrame` and - * `HttpClient.getSession`. Kept separate from `client.ts` so scenarios can - * import them directly without dragging the whole `DaemonClient` class. - */ -import type { Session } from '@moonshot-ai/kap-server/protocol/session'; - -import type { HttpClient } from './http.js'; -import type { AnyFrame, WsClient } from './ws.js'; - -/** Default 60s wait for a single event frame — matches approval/question TTL. */ -export const DEFAULT_FRAME_TIMEOUT_MS = 60_000; - -/** - * Wait for the first WS frame matching `predicate`. Thin wrapper that fills - * the default timeout — most scenarios shouldn't have to think about it. - */ -export function waitForFrame( - ws: WsClient, - predicate: (frame: AnyFrame) => boolean, - opts?: { timeoutMs?: number }, -): Promise { - return ws.waitForFrame(predicate, opts?.timeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS); -} - -/** - * Poll `GET /sessions/{sid}` until aggregate `busy` matches. Useful as a - * final synchronization point — the server's `turn.ended` arrives before - * the session work projection flips to false, so scenarios that want a - * quiescent session must poll. - */ -export async function waitForSessionBusy( - http: HttpClient, - sid: string, - busy: boolean, - opts?: { timeoutMs?: number; pollMs?: number }, -): Promise { - const timeoutMs = opts?.timeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS; - const pollMs = opts?.pollMs ?? 250; - const deadline = Date.now() + timeoutMs; - let last: Session | null = null; - while (Date.now() < deadline) { - const session = await http.getSession(sid); - last = session; - if (session.busy === busy) return session; - await sleep(pollMs); - } - throw new Error( - `session ${sid} did not reach busy=${busy} within ${timeoutMs}ms ` + - `(last busy=${last?.busy ?? 'unknown'})`, - ); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/packages/klient/test/e2e/harness/ws.ts b/packages/klient/test/e2e/harness/ws.ts deleted file mode 100644 index c5538b07c7a..00000000000 --- a/packages/klient/test/e2e/harness/ws.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * WS layer for `DaemonClient` — owns the socket, queues incoming frames so - * fast tests don't race the first `server_hello`, exposes a `waitForFrame` - * with timeouts, and routes control-message acks back to the original sender - * via `id` correlation. - * - * Frame shape is the union of WS.md §2 envelopes: - * - `event` envelope : `{type, seq, session_id, timestamp, payload}` - * - `ack` : `{type:'ack', id, code, msg, payload}` - * - `server_hello`/`ping`/`resync_required`/`error`: each carries `timestamp` - * - * We don't Zod-validate frames here — preserving forward-compat ("unknown - * fields pass through") and avoiding double-work since the server emits the - * shapes already. - */ -import { WebSocket as WsWebSocket } from 'ws'; - -import { recordReportEvent } from './report.js'; - -/** Wire frame shape — kept loose because the server adds new event types. */ -export interface AnyFrame { - readonly type: string; - readonly seq?: number; - readonly session_id?: string; - readonly timestamp?: string; - readonly id?: string; - readonly code?: number; - readonly msg?: string; - readonly payload?: unknown; -} - -export interface WsClientOptions { - url: string; - wsImpl: typeof WsWebSocket; - logger: (level: 'info' | 'warn' | 'error' | 'debug', msg: string, meta?: unknown) => void; - reportDir?: string; -} - -type FrameWaiter = (frame: AnyFrame) => boolean; - -interface PendingWaiter { - match: FrameWaiter; - resolve: (frame: AnyFrame) => void; - reject: (err: Error) => void; - timer?: NodeJS.Timeout; -} - -/** - * Thin WS wrapper. Two-tier delivery: - * - All frames also fan out to subscribers added with `onFrame()`. - * - `waitForFrame(predicate)` consumes the *first matching* frame; matching - * frames already in `_queue` are dispatched immediately. - * - * Both queue and waiters are needed because the server's first `server_hello` - * can land in the same tick as `open`, before the test has a chance to - * register its first waiter (see `server/test/ws-handshake.e2e.test.ts:88-117` - * for the pattern this is ported from). - */ -export class WsClient { - private ws: WsWebSocket | null = null; - private readonly _queue: AnyFrame[] = []; - private readonly _waiters: PendingWaiter[] = []; - private readonly _subscribers = new Set<(f: AnyFrame) => void>(); - private _closed = false; - private _closeReason: { code: number; reason: string } | null = null; - private _closeWaiters: Array<(v: { code: number; reason: string }) => void> = []; - - constructor(private readonly opts: WsClientOptions) {} - - /** Open the socket; resolves once `open` fires. */ - async open(): Promise { - if (this.ws) return; - await new Promise((resolve, reject) => { - const ws = new this.opts.wsImpl(this.opts.url); - this.ws = ws; - ws.once('open', () => { - recordReportEvent( - { kind: 'ws', direction: 'lifecycle', url: this.opts.url, message: 'open' }, - { reportDir: this.opts.reportDir }, - ); - resolve(); - }); - ws.once('error', (err) => { - if (this._closed) return; - recordReportEvent( - { - kind: 'ws', - direction: 'lifecycle', - url: this.opts.url, - message: 'error', - error: errorForReport(err), - }, - { reportDir: this.opts.reportDir }, - ); - reject(err as Error); - }); - ws.on('message', (data) => this._onMessage(data)); - ws.on('close', (code, reason) => this._onClose(code, String(reason ?? ''))); - }); - } - - /** JSON-stringifies and sends a frame. */ - send(frame: object): void { - if (!this.ws) throw new Error('ws not open'); - this.ws.send(JSON.stringify(frame)); - recordReportEvent( - { kind: 'ws', direction: 'out', url: this.opts.url, frame }, - { reportDir: this.opts.reportDir }, - ); - } - - /** Register a frame subscriber. Returns an unsubscribe handle. */ - onFrame(handler: (f: AnyFrame) => void): () => void { - this._subscribers.add(handler); - return () => { - this._subscribers.delete(handler); - }; - } - - /** - * Wait for the next frame matching `predicate`. Drains queued frames first; - * the first matching frame is consumed and returned. Times out cleanly. - */ - waitForFrame(predicate: FrameWaiter, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - // Drain queue. - for (let i = 0; i < this._queue.length; i++) { - const frame = this._queue[i]; - if (frame === undefined) continue; - if (predicate(frame)) { - this._queue.splice(i, 1); - resolve(frame); - return; - } - } - if (this._closed) { - reject(new Error(`ws closed before matching frame arrived (code=${this._closeReason?.code})`)); - return; - } - const waiter: PendingWaiter = { - match: predicate, - resolve: (f) => { - if (waiter.timer) clearTimeout(waiter.timer); - resolve(f); - }, - reject: (e) => { - if (waiter.timer) clearTimeout(waiter.timer); - reject(e); - }, - }; - waiter.timer = setTimeout(() => { - const idx = this._waiters.indexOf(waiter); - if (idx >= 0) this._waiters.splice(idx, 1); - reject(new Error(`waitForFrame timed out after ${timeoutMs}ms`)); - }, timeoutMs); - waiter.timer.unref?.(); - this._waiters.push(waiter); - }); - } - - /** Send a control message and wait for its `ack` (matched by `id`). */ - async sendAndAwaitAck(frame: { type: string; id: string; payload: unknown }, timeoutMs: number): Promise { - this.send(frame); - return this.waitForFrame( - (f) => f.type === 'ack' && f.id === frame.id, - timeoutMs, - ); - } - - /** Resolves when the socket closes (or immediately if already closed). */ - closed(): Promise<{ code: number; reason: string }> { - if (this._closeReason) return Promise.resolve(this._closeReason); - return new Promise((resolve) => { - this._closeWaiters.push(resolve); - }); - } - - /** Initiate close from the client side. */ - async close(): Promise { - if (!this.ws || this._closed) return; - this.ws.close(); - await this.closed(); - } - - private _onMessage(data: unknown): void { - let frame: AnyFrame; - try { - const raw = typeof data === 'string' ? data : String(data); - frame = JSON.parse(raw) as AnyFrame; - } catch (err) { - this.opts.logger('warn', 'ws: dropped non-JSON frame', { err: String(err) }); - recordReportEvent( - { - kind: 'ws', - direction: 'in', - url: this.opts.url, - message: 'dropped non-JSON frame', - error: errorForReport(err), - }, - { reportDir: this.opts.reportDir }, - ); - return; - } - recordReportEvent( - { kind: 'ws', direction: 'in', url: this.opts.url, frame }, - { reportDir: this.opts.reportDir }, - ); - - if (frame.type === 'ping') { - this.send({ type: 'pong', payload: { nonce: pingNonce(frame) } }); - } - - // Dispatch to subscribers first — they observe every frame, regardless of - // whether a `waitForFrame` consumed it. - for (const sub of this._subscribers) { - try { - sub(frame); - } catch (err) { - this.opts.logger('warn', 'ws: subscriber threw', { err: String(err) }); - } - } - - // Find the FIRST waiter whose predicate matches. A waiter is single-shot. - for (let i = 0; i < this._waiters.length; i++) { - const w = this._waiters[i]; - if (w === undefined) continue; - let matches = false; - try { - matches = w.match(frame); - } catch (err) { - this.opts.logger('warn', 'ws: waiter predicate threw', { err: String(err) }); - } - if (matches) { - this._waiters.splice(i, 1); - w.resolve(frame); - return; - } - } - this._queue.push(frame); - } - - private _onClose(code: number, reason: string): void { - this._closed = true; - this._closeReason = { code, reason }; - recordReportEvent( - { - kind: 'ws', - direction: 'lifecycle', - url: this.opts.url, - message: 'close', - frame: { code, reason }, - }, - { reportDir: this.opts.reportDir }, - ); - for (const w of this._waiters.splice(0)) { - w.reject(new Error(`ws closed (code=${code}) before matching frame arrived`)); - } - for (const w of this._closeWaiters.splice(0)) w(this._closeReason); - } -} - -function pingNonce(frame: AnyFrame): string { - const payload = frame.payload as { nonce?: unknown } | undefined; - return typeof payload?.nonce === 'string' ? payload.nonce : ''; -} - -function errorForReport(error: unknown): unknown { - if (error instanceof Error) { - return { - name: error.name, - message: error.message, - }; - } - return error; -} diff --git a/packages/klient/test/e2e/legacy/client.test.ts b/packages/klient/test/e2e/legacy/client.test.ts deleted file mode 100644 index 8739d1eeec6..00000000000 --- a/packages/klient/test/e2e/legacy/client.test.ts +++ /dev/null @@ -1,726 +0,0 @@ -/** - * Self-tests for `DaemonClient` against a live server at - * `process.env.KIMI_SERVER_URL ?? http://127.0.0.1:58627`. - * - * Every test gates on a `daemonReachable()` check so CI / dev machines - * without a running server stay green. Run a server (`pnpm dev:server` from - * repo root) to exercise these locally. - * - * Coverage: - * 1. HTTP envelope unwrap throws on `code !== 0`. - * 2. WS handshake completes (server_hello + client_hello ack). - * 3. Subscribe ack succeeds for a real session id. - * 4. `waitForFrame` times out cleanly (no zombie waiters). - * 5. Created session is observable via `getSession`. - */ -import { afterEach, describe, expect, it } from 'vitest'; - -import type { FileMeta } from '@moonshot-ai/agent-core-v2/app/file/fileService'; -import type { - ModelCatalogItem, - ProviderCatalogItem, -} from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; -import { ErrorCode } from '@moonshot-ai/kap-server/protocol/error-codes'; -import type { Message } from '@moonshot-ai/kap-server/protocol/message'; -import type { SessionStatusResponse } from '@moonshot-ai/kap-server/protocol/rest-session'; -import type { Session } from '@moonshot-ai/kap-server/protocol/session'; - -import { DaemonClient, EnvelopeError } from '../harness/index.js'; -import { fetchWithReport } from '../harness/report.js'; -import { createCaseLogger, errorForLog } from './log.js'; - -const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -const PROMPT_TIMEOUT_MS = 120_000; - -async function daemonReachable(): Promise { - try { - const res = await fetchWithReport(`${BASE_URL}/api/v1/meta`, { - signal: AbortSignal.timeout(500), - }); - return res.ok; - } catch { - return false; - } -} - -const reachable = await daemonReachable(); -const describeLive = reachable ? describe : describe.skip; - -let created: { client: DaemonClient; sid: string }[] = []; - -afterEach(async () => { - // Best-effort cleanup so reruns don't accumulate phantom sessions. - for (const { client, sid } of created.splice(0)) { - try { - await client.archiveSession(sid); - } catch { - // ignore - } - try { - await client.close(); - } catch { - // ignore - } - } -}); - -describeLive('DaemonClient (live server required)', () => { - it('throws EnvelopeError on code !== 0', async () => { - const log = createCaseLogger('client: missing session envelope'); - const client = new DaemonClient({ baseUrl: BASE_URL }); - const sid = 'sess_does_not_exist_xxxxxxxx'; - log('request', { method: 'GET', path: `/api/v1/sessions/${sid}` }); - - let caughtError: unknown; - try { - await client.getSession(sid); - } catch (error) { - caughtError = error; - } - if (caughtError === undefined) { - throw new Error('expected getSession to reject for a missing session'); - } - log('error response', errorForLog(caughtError)); - expect(caughtError).toBeInstanceOf(EnvelopeError); - }); - - it('completes handshake (server_hello + client_hello ack)', async () => { - const log = createCaseLogger('client: ws handshake'); - const client = new DaemonClient({ baseUrl: BASE_URL }); - log('connect request', { url: `${BASE_URL.replace(/^http/, 'ws')}/api/v1/ws` }); - const hello = await client.connect(); - log('server hello', hello); - // heartbeat_ms is optional — kap-server omits it (no server heartbeat). - expect(hello.heartbeat_ms === undefined || hello.heartbeat_ms > 0).toBe(true); - expect(typeof hello.ws_connection_id).toBe('string'); - await client.close(); - log('closed'); - }); - - it('subscribes to a real session id', async () => { - const log = createCaseLogger('client: subscribe real session'); - const client = new DaemonClient({ baseUrl: BASE_URL }); - const session = await client.createSession({ metadata: { cwd: process.cwd() } }); - created.push({ client, sid: session.id }); - log('created session', session); - await client.connect(); - log('subscribe request', { type: 'subscribe', session_ids: [session.id] }); - await expect(client.subscribe(session.id)).resolves.toBeUndefined(); - log('subscribe accepted', { session_id: session.id }); - }); - - it('waitForFrame times out cleanly', async () => { - const log = createCaseLogger('client: waitForFrame timeout'); - const client = new DaemonClient({ baseUrl: BASE_URL }); - await client.connect(); - log('wait request', { frame_type: 'event.does.not.exist', timeout_ms: 100 }); - let caughtError: unknown; - try { - await client.waitForFrame((f) => f.type === 'event.does.not.exist', { timeoutMs: 100 }); - } catch (error) { - caughtError = error; - } - if (caughtError === undefined) { - throw new Error('expected waitForFrame to time out'); - } - log('timeout error', errorForLog(caughtError)); - expect(caughtError).toBeInstanceOf(Error); - expect((caughtError as Error).message).toMatch(/waitForFrame timed out/); - await client.close(); - log('closed'); - }); - - it('created session is readable via getSession', async () => { - const log = createCaseLogger('client: getSession round trip'); - const client = new DaemonClient({ baseUrl: BASE_URL }); - const session = await client.createSession({ metadata: { cwd: process.cwd() } }); - created.push({ client, sid: session.id }); - log('created session', session); - const fetched = await client.getSession(session.id); - log('fetched session', fetched); - expect(fetched.id).toBe(session.id); - expect(fetched.metadata.cwd).toBe(process.cwd()); - }); - - it('forks a session through the action-suffix route', async () => { - const log = createCaseLogger('client: fork action'); - const client = new DaemonClient({ baseUrl: BASE_URL }); - const source = await client.createSession({ - title: 'Source session', - metadata: { cwd: process.cwd(), source: true }, - }); - created.push({ client, sid: source.id }); - log('source session', source); - - const forkRequest = { - metadata: { child: true }, - }; - log('request', { - method: 'POST', - path: `/api/v1/sessions/${source.id}:fork`, - body: forkRequest, - }); - - const fork = await client.forkSession(source.id, forkRequest); - created.push({ client, sid: fork.id }); - log('response', fork); - - expect(fork.id).not.toBe(source.id); - expect(fork.title).toBe('Fork: Source session'); - expect(fork.metadata).toMatchObject({ - cwd: process.cwd(), - source: true, - child: true, - }); - - const fetched = await client.getSession(fork.id); - log('fetched fork session', fetched); - expect(fetched.id).toBe(fork.id); - }); - - it( - 'compactSession prints empty-history errors and compacted history content', - async () => { - const log = createCaseLogger('client: compact empty history'); - const client = new DaemonClient({ baseUrl: BASE_URL }); - const session = await client.createSession({ metadata: { cwd: process.cwd() } }); - created.push({ client, sid: session.id }); - log('source session', session); - - const compactRequest = { instruction: ' focus on decisions ' }; - log('request', { - method: 'POST', - path: `/api/v1/sessions/${session.id}:compact`, - body: compactRequest, - }); - - let compactError: unknown; - try { - const result = await client.compactSession(session.id, compactRequest); - log('response', result); - } catch (error) { - compactError = error; - } - if (compactError === undefined) { - throw new Error('expected compactSession to reject for an empty-history session'); - } - log('error response', errorForLog(compactError)); - - expect(compactError).toMatchObject({ - code: ErrorCode.COMPACTION_UNABLE, - reason: 'compaction.unable', - data: null, - }); - - const successLog = createCaseLogger('client: compact populated history'); - const populated = await client.createSession({ metadata: { cwd: process.cwd() } }); - created.push({ client, sid: populated.id }); - successLog('source session', populated); - - await client.connect(); - await client.subscribe(populated.id); - successLog('subscribe accepted', { session_id: populated.id }); - - const promptResult = await client.submitAndWait( - populated.id, - { - content: [ - { - type: 'text', - text: 'Remember this compact-test fact: the code word is BLUE. Reply with "OK".', - }, - ], - }, - { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, - ); - successLog('seed prompt completed', { - prompt_id: promptResult.prompt_id, - user_message_id: promptResult.user_message_id, - final_frame: frameForLog(promptResult.finalFrame), - }); - - const beforeCompact = await client.listMessages(populated.id, { page_size: 100 }); - successLog('messages before compact', beforeCompact); - expect(beforeCompact.items.some((m) => m.role === 'user')).toBe(true); - expect(beforeCompact.items.some((m) => m.role === 'assistant')).toBe(true); - - const populatedCompactRequest = { - instruction: 'Preserve the compact-test code word and the fact that the assistant replied OK.', - }; - successLog('request', { - method: 'POST', - path: `/api/v1/sessions/${populated.id}:compact`, - body: populatedCompactRequest, - }); - - const completedPromise = client.waitForFrame( - (f) => f.type === 'compaction.completed' && f.session_id === populated.id, - { timeoutMs: PROMPT_TIMEOUT_MS }, - ); - const compactResponse = await client.compactSession(populated.id, populatedCompactRequest); - successLog('rest response', compactResponse); - const completedFrame = await completedPromise; - successLog('compaction completed frame', frameForLog(completedFrame)); - - const afterCompact = await client.listMessages(populated.id, { page_size: 100 }); - successLog('messages after compact', afterCompact); - - const compactedText = afterCompact.items - .flatMap((m) => m.content) - .filter((part) => part.type === 'text') - .map((part) => part.text) - .join('\n'); - successLog('compacted text content', { text: compactedText }); - - expect(afterCompact.items.length).toBeGreaterThan(0); - expect(compactedText.length).toBeGreaterThan(0); - }, - PROMPT_TIMEOUT_MS + 30_000, - ); - - it( - 'undoSession removes the latest prompt and returns refreshed messages plus status', - async () => { - const log = createCaseLogger('client: undo action'); - const client = new DaemonClient({ baseUrl: BASE_URL }); - const session = await client.createSession({ metadata: { cwd: process.cwd() } }); - created.push({ client, sid: session.id }); - log('source session', session); - - await client.connect(); - await client.subscribe(session.id); - log('subscribe accepted', { session_id: session.id }); - - const keepPrompt = await client.submitAndWait( - session.id, - { content: [{ type: 'text', text: 'Remember KEEP. Reply with "OK".' }] }, - { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, - ); - log('keep prompt completed', { - prompt_id: keepPrompt.prompt_id, - user_message_id: keepPrompt.user_message_id, - final_frame: frameForLog(keepPrompt.finalFrame), - }); - - const undoPrompt = await client.submitAndWait( - session.id, - { content: [{ type: 'text', text: 'Remember UNDO-ME. Reply with "OK".' }] }, - { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, - ); - log('undo prompt completed', { - prompt_id: undoPrompt.prompt_id, - user_message_id: undoPrompt.user_message_id, - final_frame: frameForLog(undoPrompt.finalFrame), - }); - - const beforeUndo = await client.listMessages(session.id, { page_size: 100 }); - log('messages before undo', beforeUndo); - expect(textFromMessages(beforeUndo.items)).toContain('UNDO-ME'); - - const result = await client.undoSession(session.id, { count: 1, page_size: 100 }); - log('undo response', result); - - const afterText = textFromMessages(result.messages.items); - expect(afterText).toContain('KEEP'); - expect(afterText).not.toContain('UNDO-ME'); - expect(result.messages.has_more).toBe(false); - expect(result.status.context_tokens).toBeGreaterThanOrEqual(0); - }, - PROMPT_TIMEOUT_MS * 2 + 30_000, - ); -}); - -describe('DaemonClient session action helpers', () => { - it('forkSession posts the action-suffix route and unwraps the returned session', async () => { - const log = createCaseLogger('client helper: forkSession'); - const calls: FetchCall[] = []; - const fork = testSession({ id: 'sess_fork', title: 'Fork: Source session' }); - const client = new DaemonClient({ - baseUrl: 'http://server.example.test', - fetchImpl: recordingFetch(okEnvelope(fork), calls), - }); - - const result = await client.forkSession('sess_source', { - title: 'Custom fork', - metadata: { child: true }, - }); - - log('fetch calls', calls); - log('unwrapped result', result); - expect(result).toEqual(fork); - expect(calls).toHaveLength(1); - expect(calls[0]?.url).toBe('http://server.example.test/api/v1/sessions/sess_source:fork'); - expect(calls[0]?.init.method).toBe('POST'); - expect(parseRecordedJsonBody(calls[0])).toEqual({ - title: 'Custom fork', - metadata: { child: true }, - }); - }); - - it('compactSession posts the action-suffix route and preserves error envelope details', async () => { - const log = createCaseLogger('client helper: compactSession'); - const calls: FetchCall[] = []; - const client = new DaemonClient({ - baseUrl: 'http://server.example.test', - fetchImpl: recordingFetch( - { - code: ErrorCode.COMPACTION_UNABLE, - msg: 'No prefix can be compacted.', - data: null, - request_id: 'req_test', - }, - calls, - ), - }); - - let caughtError: unknown; - try { - await client.compactSession('sess_source', { instruction: ' focus on decisions ' }); - } catch (error) { - caughtError = error; - } - if (caughtError === undefined) { - throw new Error('expected compactSession to reject for a non-zero envelope'); - } - - log('fetch calls', calls); - log('error response', errorForLog(caughtError)); - expect(caughtError).toMatchObject({ - code: ErrorCode.COMPACTION_UNABLE, - reason: 'compaction.unable', - requestId: 'req_test', - data: null, - }); - - expect(calls).toHaveLength(1); - expect(calls[0]?.url).toBe('http://server.example.test/api/v1/sessions/sess_source:compact'); - expect(calls[0]?.init.method).toBe('POST'); - expect(parseRecordedJsonBody(calls[0])).toEqual({ - instruction: ' focus on decisions ', - }); - }); - - it('undoSession posts the action-suffix route and unwraps messages plus status', async () => { - const log = createCaseLogger('client helper: undoSession'); - const calls: FetchCall[] = []; - const message = testMessage({ id: 'msg_kept', session_id: 'sess_source' }); - const undoResponse = { - messages: { items: [message], has_more: false }, - status: testSessionStatus(), - }; - const client = new DaemonClient({ - baseUrl: 'http://server.example.test', - fetchImpl: recordingFetch(okEnvelope(undoResponse), calls), - }); - - const result = await client.undoSession('sess_source', { count: 2, page_size: 25 }); - - log('fetch calls', calls); - log('unwrapped result', result); - expect(result).toEqual(undoResponse); - expect(calls).toHaveLength(1); - expect(calls[0]?.url).toBe('http://server.example.test/api/v1/sessions/sess_source:undo'); - expect(calls[0]?.init.method).toBe('POST'); - expect(parseRecordedJsonBody(calls[0])).toEqual({ - count: 2, - page_size: 25, - }); - }); - - it('model catalog helpers call the catalog and action-suffix routes', async () => { - const log = createCaseLogger('client helper: model catalog'); - const calls: FetchCall[] = []; - const model = testModel({ model: 'kimi-code/kimi-for-coding' }); - const provider = testProvider({ id: 'kimi', models: [model.model] }); - const client = new DaemonClient({ - baseUrl: 'http://server.example.test', - fetchImpl: recordingFetchSequence( - [ - okEnvelope({ - models_ready: true, - providers_count: 1, - managed_provider: null, - }), - okEnvelope({ items: [model] }), - okEnvelope({ default_model: model.model, model }), - okEnvelope({ items: [provider] }), - okEnvelope(provider), - ], - calls, - ), - }); - - await expect(client.getAuth()).resolves.toMatchObject({ models_ready: true }); - await expect(client.listModels()).resolves.toEqual({ items: [model] }); - await expect(client.setDefaultModel(model.model)).resolves.toEqual({ - default_model: model.model, - model, - }); - await expect(client.listProviders()).resolves.toEqual({ items: [provider] }); - await expect(client.getProvider('kimi')).resolves.toEqual(provider); - - log('fetch calls', calls); - expect(calls.map((call) => [call.init.method, call.url])).toEqual([ - ['GET', 'http://server.example.test/api/v1/auth'], - ['GET', 'http://server.example.test/api/v1/models'], - ['POST', 'http://server.example.test/api/v1/models/kimi-code%2Fkimi-for-coding:set_default'], - ['GET', 'http://server.example.test/api/v1/providers'], - ['GET', 'http://server.example.test/api/v1/providers/kimi'], - ]); - expect(parseRecordedJsonBody(calls[2])).toEqual({}); - }); - - it('child-session and pending reverse-RPC helpers call recovery routes', async () => { - const log = createCaseLogger('client helper: children + pending'); - const calls: FetchCall[] = []; - const child = testSession({ id: 'sess_child', title: 'Child session' }); - const client = new DaemonClient({ - baseUrl: 'http://server.example.test', - fetchImpl: recordingFetchSequence( - [ - okEnvelope(child), - okEnvelope({ items: [child], has_more: false }), - okEnvelope({ items: [] }), - okEnvelope({ items: [] }), - okEnvelope({ dismissed: true, dismissed_at: '2026-06-09T00:00:00.000Z' }), - ], - calls, - ), - }); - - await expect( - client.createChild('sess_parent', { - title: 'Child session', - metadata: { topic: 'side-question' }, - }), - ).resolves.toEqual(child); - await expect( - client.listChildren('sess_parent', { page_size: 5, busy: false }), - ).resolves.toEqual({ items: [child], has_more: false }); - await expect(client.listPendingApprovals('sess_parent')).resolves.toEqual({ items: [] }); - await expect(client.listPendingQuestions('sess_parent')).resolves.toEqual({ items: [] }); - await expect(client.dismissQuestion('sess_parent', 'question_1')).resolves.toEqual({ - dismissed: true, - dismissed_at: '2026-06-09T00:00:00.000Z', - }); - - log('fetch calls', calls); - expect(calls.map((call) => [call.init.method, call.url])).toEqual([ - ['POST', 'http://server.example.test/api/v1/sessions/sess_parent/children'], - ['GET', 'http://server.example.test/api/v1/sessions/sess_parent/children?page_size=5&busy=false'], - ['GET', 'http://server.example.test/api/v1/sessions/sess_parent/approvals?status=pending'], - ['GET', 'http://server.example.test/api/v1/sessions/sess_parent/questions?status=pending'], - ['POST', 'http://server.example.test/api/v1/sessions/sess_parent/questions/question_1:dismiss'], - ]); - expect(parseRecordedJsonBody(calls[0])).toEqual({ - title: 'Child session', - metadata: { topic: 'side-question' }, - }); - expect(parseRecordedJsonBody(calls[4])).toEqual({}); - }); - - it('uploadFile posts multipart form data and deleteFile hits the file route', async () => { - const log = createCaseLogger('client helper: file upload'); - const calls: FetchCall[] = []; - const file = testFile({ id: 'file_png', name: 'tiny.png', media_type: 'image/png', size: 3 }); - const client = new DaemonClient({ - baseUrl: 'http://server.example.test', - fetchImpl: recordingFetchSequence( - [ - okEnvelope(file), - okEnvelope({ deleted: true }), - ], - calls, - ), - }); - - await expect( - client.uploadFile({ - name: 'tiny.png', - data: new Uint8Array([1, 2, 3]), - mediaType: 'image/png', - expiresInSec: 60, - }), - ).resolves.toEqual(file); - await expect(client.deleteFile(file.id)).resolves.toEqual({ deleted: true }); - - log('fetch calls', calls); - expect(calls.map((call) => [call.init.method, call.url])).toEqual([ - ['POST', 'http://server.example.test/api/v1/files'], - ['DELETE', 'http://server.example.test/api/v1/files/file_png'], - ]); - const form = calls[0]?.init.body; - expect(form).toBeInstanceOf(FormData); - const upload = form as FormData; - expect(upload.get('name')).toBe('tiny.png'); - expect(upload.get('expires_in_sec')).toBe('60'); - const filePart = upload.get('file'); - expect(filePart).toBeInstanceOf(Blob); - expect((filePart as Blob).type).toBe('image/png'); - expect((filePart as Blob).size).toBe(3); - }); -}); - -interface FetchCall { - url: string; - init: RequestInit; -} - -function recordingFetch(responseBody: unknown, calls: FetchCall[]): typeof fetch { - return (async (input: Parameters[0], init?: Parameters[1]) => { - calls.push({ url: fetchInputUrl(input), init: init ?? {} }); - return new Response(JSON.stringify(responseBody), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - }) as typeof fetch; -} - -function recordingFetchSequence(responseBodies: unknown[], calls: FetchCall[]): typeof fetch { - let index = 0; - return (async (input: Parameters[0], init?: Parameters[1]) => { - calls.push({ url: fetchInputUrl(input), init: init ?? {} }); - const responseBody = responseBodies[Math.min(index, responseBodies.length - 1)]; - index++; - return new Response(JSON.stringify(responseBody), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - }) as typeof fetch; -} - -function fetchInputUrl(input: Parameters[0]): string { - if (typeof input === 'string') return input; - if (input instanceof URL) return input.toString(); - return input.url; -} - -function parseRecordedJsonBody(call: FetchCall | undefined): unknown { - const body = call?.init.body; - if (typeof body !== 'string') { - throw new TypeError('expected recorded fetch body to be a JSON string'); - } - return JSON.parse(body) as unknown; -} - -function okEnvelope(data: T): { code: 0; msg: string; data: T; request_id: string } { - return { code: 0, msg: 'success', data, request_id: 'req_test' }; -} - -function frameForLog(frame: { - type: string; - seq?: number; - session_id?: string; - id?: string; - code?: number; - msg?: string; - payload?: unknown; -}): Record { - return { - type: frame.type, - seq: frame.seq, - session_id: frame.session_id, - id: frame.id, - code: frame.code, - msg: frame.msg, - payload: frame.payload, - }; -} - -function testSession(overrides: Partial = {}): Session { - const base: Session = { - id: 'sess_example', - workspace_id: 'wd_example_0123456789ab', - title: 'Example session', - created_at: '2026-06-09T00:00:00.000Z', - updated_at: '2026-06-09T00:00:00.000Z', - busy: false, - metadata: { cwd: '/tmp/example-server-e2e' }, - agent_config: { model: '' }, - usage: { - input_tokens: 0, - output_tokens: 0, - cache_read_tokens: 0, - cache_creation_tokens: 0, - total_cost_usd: 0, - context_tokens: 0, - context_limit: 0, - turn_count: 0, - }, - permission_rules: [], - message_count: 0, - last_seq: 0, - }; - return { - ...base, - ...overrides, - metadata: { ...base.metadata, ...overrides.metadata }, - }; -} - -function testModel(overrides: Partial = {}): ModelCatalogItem { - return { - provider: 'kimi', - model: 'k2', - display_name: 'Kimi K2', - max_context_size: 131_072, - ...overrides, - }; -} - -function testProvider(overrides: Partial = {}): ProviderCatalogItem { - return { - id: 'kimi', - type: 'kimi', - base_url: 'https://api.example.test/v1', - default_model: 'k2', - has_api_key: true, - status: 'connected', - models: ['k2'], - ...overrides, - }; -} - -function testFile(overrides: Partial = {}): FileMeta { - return { - id: 'file_example', - name: 'example.txt', - media_type: 'text/plain', - size: 0, - created_at: '2026-06-09T00:00:00.000Z', - ...overrides, - }; -} - -function testMessage(overrides: Partial = {}): Message { - return { - id: 'msg_example', - session_id: 'sess_example', - role: 'user', - content: [{ type: 'text', text: 'kept' }], - created_at: '2026-06-09T00:00:00.000Z', - ...overrides, - }; -} - -function testSessionStatus(): SessionStatusResponse { - return { - busy: false, - model: 'kimi-code/kimi-for-coding', - thinking_level: 'off', - permission: 'manual', - plan_mode: false, - swarm_mode: false, - context_tokens: 0, - max_context_tokens: 100, - context_usage: 0, - }; -} - -function textFromMessages(messages: Array<{ content: Array<{ type: string; text?: string }> }>): string { - return messages - .flatMap((message) => message.content) - .filter((part) => part.type === 'text') - .map((part) => part.text ?? '') - .join('\n'); -} diff --git a/packages/klient/test/e2e/legacy/image-file-prompts.test.ts b/packages/klient/test/e2e/legacy/image-file-prompts.test.ts deleted file mode 100644 index 6ff77686cff..00000000000 --- a/packages/klient/test/e2e/legacy/image-file-prompts.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Live-server invariant for uploaded image files in prompt content (v1 REST - * surface only — the facade has no file-upload method): - * - a missing prompt image `file_id` returns `FILE_NOT_FOUND`; - * - a non-image uploaded file used as image content returns `VALIDATION_FAILED`; - * - an uploaded PNG can be referenced by a prompt submission, and the - * prompt can be aborted (or was already terminal). - * - * Converted from the retired scenario `09-image-file-prompts.ts`. Skips when - * no server is reachable at `KIMI_SERVER_URL`. - */ -import { describe, expect, it } from 'vitest'; - -import { ErrorCode } from '@moonshot-ai/kap-server/protocol/error-codes'; - -import { DaemonClient, EnvelopeError } from '../harness/index.js'; -import { fetchWithReport } from '../harness/report.js'; -import { createCaseLogger } from './log.js'; - -const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -const API_PREFIX = '/api/v1'; -const SHORT_TIMEOUT_MS = 15_000; - -const ONE_BY_ONE_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', - 'base64', -); - -async function daemonReachable(): Promise { - try { - const res = await fetchWithReport(`${BASE_URL}${API_PREFIX}/meta`, { - signal: AbortSignal.timeout(500), - }); - return res.ok; - } catch { - return false; - } -} - -const reachable = await daemonReachable(); -const describeLive = reachable ? describe : describe.skip; - -describeLive('legacy: image file prompts', () => { - it('missing/non-image files rejected, PNG accepted, prompt abortable', async () => { - const log = createCaseLogger('legacy: image file prompts'); - const client = new DaemonClient({ baseUrl: BASE_URL }); - const files: string[] = []; - let sid: string | undefined; - - async function expectEnvelopeCode( - action: () => Promise, - code: ErrorCode, - label: string, - ): Promise { - let caught: unknown; - try { - await action(); - } catch (error) { - caught = error; - } - expect(caught, label).toBeInstanceOf(EnvelopeError); - expect((caught as EnvelopeError).code, label).toBe(code); - log(label, { code: (caught as EnvelopeError).code }); - } - - try { - const session = await client.createSession({ - title: 'klient-e2e image file prompts', - metadata: { cwd: process.cwd(), scenario: 'image-file-prompts' }, - }); - sid = session.id; - log('session created', { session_id: sid }); - - await expectEnvelopeCode( - () => - client.submitPrompt(sid!, { - content: [{ type: 'image', source: { kind: 'file', file_id: 'file_missing_e2e' } }], - }), - ErrorCode.FILE_NOT_FOUND, - 'missing prompt image file_id', - ); - - const textFile = await client.uploadFile({ - name: 'not-an-image.txt', - data: 'not an image', - mediaType: 'text/plain', - }); - files.push(textFile.id); - await expectEnvelopeCode( - () => - client.submitPrompt(sid!, { - content: [{ type: 'image', source: { kind: 'file', file_id: textFile.id } }], - }), - ErrorCode.VALIDATION_FAILED, - 'non-image prompt file_id', - ); - - const png = await client.uploadFile({ - name: 'tiny.png', - data: ONE_BY_ONE_PNG, - mediaType: 'image/png', - }); - files.push(png.id); - expect(png.media_type).toBe('image/png'); - expect(png.size).toBe(ONE_BY_ONE_PNG.length); - - const submit = await client.submitPrompt(sid, { - content: [ - { type: 'text', text: 'Reply with the single word "OK" after reading this image.' }, - { type: 'image', source: { kind: 'file', file_id: png.id } }, - ], - }); - expect(submit.prompt_id.length).toBeGreaterThan(0); - log('prompt submitted', { file_id: png.id, prompt_id: submit.prompt_id }); - - try { - await client.abortPrompt(sid, submit.prompt_id); - log('prompt aborted', { prompt_id: submit.prompt_id }); - } catch (error) { - if ( - error instanceof EnvelopeError && - (error.code === ErrorCode.PROMPT_ALREADY_COMPLETED || - error.code === ErrorCode.PROMPT_NOT_FOUND) - ) { - log('prompt already terminal before abort', { prompt_id: submit.prompt_id }); - } else { - throw error; - } - } - await client.waitForSessionBusy(sid, false, { timeoutMs: SHORT_TIMEOUT_MS }); - } finally { - for (const fileId of files.toReversed()) { - try { - await client.deleteFile(fileId); - } catch { - // ignore - } - } - try { - if (sid) await client.archiveSession(sid); - } catch { - // ignore - } - await client.close(); - } - }, 120_000); -}); diff --git a/packages/klient/test/e2e/legacy/log.ts b/packages/klient/test/e2e/legacy/log.ts deleted file mode 100644 index 90cb2363244..00000000000 --- a/packages/klient/test/e2e/legacy/log.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { onTestFailed, onTestFinished } from 'vitest'; - -import { recordReportEvent, setActiveReportCase } from '../harness/report.js'; - -export function createCaseLogger(caseName: string): (label: string, value?: unknown) => void { - setActiveReportCase(caseName); - let failed = false; - onTestFailed((error) => { - failed = true; - recordReportEvent({ - kind: 'test-result', - caseName, - state: 'failed', - error: errorForLog(error), - }); - }); - onTestFinished(() => { - if (failed) return; - recordReportEvent({ - kind: 'test-result', - caseName, - state: 'passed', - }); - }); - return (label, value) => { - recordReportEvent({ kind: 'log', caseName, label, value }); - const prefix = `[server-e2e] ${caseName} :: ${label}`; - if (value === undefined) { - writeLogLine(prefix); - return; - } - writeLogLine(`${prefix}\n${stringifyForLog(value)}`); - }; -} - -export function errorForLog(error: unknown): unknown { - if (error instanceof Error) { - return { - name: error.name, - message: error.message, - ...objectFields(error), - }; - } - return error; -} - -function objectFields(value: object): Record { - return Object.fromEntries( - Object.entries(value).filter(([, field]) => field !== undefined), - ); -} - -function stringifyForLog(value: unknown): string { - return JSON.stringify(value, null, 2); -} - -function writeLogLine(line: string): void { - process.stdout.write(`${line}\n`); -} diff --git a/packages/klient/test/e2e/legacy/prompt-queue-steer.test.ts b/packages/klient/test/e2e/legacy/prompt-queue-steer.test.ts deleted file mode 100644 index 1ce57ab2be1..00000000000 --- a/packages/klient/test/e2e/legacy/prompt-queue-steer.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Prompt queue + steer live-server invariant. - * - * Drives the TUI Ctrl-S equivalent over server REST + WS: - * 1. use the debug-only prompt test hook to mark one prompt active; - * 2. submit a second prompt and assert it is queued instead of rejected; - * 3. list prompts and assert active + queued state; - * 4. steer the queued prompt and assert `prompt.steered` is broadcast and - * the queue is drained. - * - * Requires a server launched with debug endpoints enabled. Normal production - * daemons do not expose `/debug/*`, so this file skips when that surface is - * absent. - */ -import { afterEach, describe, expect, it } from 'vitest'; - -import { DaemonClient, type AnyFrame } from '../harness/index.js'; -import { fetchWithReport } from '../harness/report.js'; -import { createCaseLogger } from './log.js'; - -const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -const API_PREFIX = '/api/v1'; -const SHORT_TIMEOUT_MS = 15_000; - -interface PromptSteeredPayload { - type: 'prompt.steered'; - sessionId: string; - activePromptId: string; - promptIds: string[]; - content: unknown[]; - steeredAt: string; -} - -async function daemonReachable(): Promise { - try { - const res = await fetchWithReport(`${BASE_URL}${API_PREFIX}/meta`, { - signal: AbortSignal.timeout(500), - }); - return res.ok; - } catch { - return false; - } -} - -async function debugPromptsReachable(): Promise { - try { - const res = await fetchWithReport( - `${BASE_URL}${API_PREFIX}/debug/prompts/debug_probe/state`, - { signal: AbortSignal.timeout(500) }, - ); - return res.ok; - } catch { - return false; - } -} - -const reachable = await daemonReachable(); -const debugReachable = reachable && await debugPromptsReachable(); -const describeLive = debugReachable ? describe : describe.skip; - -const created: Array<{ client: DaemonClient; sid: string; promptIds: string[] }> = []; - -afterEach(async () => { - for (const { client, sid, promptIds } of created.splice(0)) { - for (const promptId of promptIds.toReversed()) { - try { - await client.abortPrompt(sid, promptId); - } catch { - // ignore - } - } - try { - await client.archiveSession(sid); - } catch { - // ignore - } - try { - await client.close(); - } catch { - // ignore - } - } -}); - -describeLive('prompt queue + steer (live server required)', () => { - it( - 'queues a busy prompt and steers it into the active turn', - async () => { - const log = createCaseLogger('prompt queue: steer'); - const client = new DaemonClient({ baseUrl: BASE_URL }); - const session = await client.createSession({ - title: 'server-e2e prompt queue steer', - metadata: { cwd: process.cwd(), scenario: 'prompt-queue-steer' }, - }); - const cleanup: { client: DaemonClient; sid: string; promptIds: string[] } = { - client, - sid: session.id, - promptIds: [], - }; - created.push(cleanup); - log('created session', session); - - await client.connect(); - await client.subscribe(session.id); - log('subscribe accepted', { session_id: session.id }); - - const active = await injectActivePrompt(session.id, { - prompt_id: `prompt_debug_queue_steer_${process.pid}`, - }); - log('debug active prompt injected', active); - cleanup.promptIds.push(active.prompt_id); - - const queued = await client.submitPrompt(session.id, { - content: [ - { - type: 'text', - text: 'This queued prompt should be steered into the active turn.', - }, - ], - }); - log('queued prompt submitted', queued); - cleanup.promptIds.push(queued.prompt_id); - expect(queued.status).toBe('queued'); - - const listedBefore = await client.listPrompts(session.id); - log('prompt list before steer', listedBefore); - expect(listedBefore.active?.prompt_id).toBe(active.prompt_id); - expect(listedBefore.queued.map((prompt) => prompt.prompt_id)).toEqual([ - queued.prompt_id, - ]); - - const steerFramePromise = client.waitForFrame(isPromptSteeredFor(session.id, queued.prompt_id), { - timeoutMs: SHORT_TIMEOUT_MS, - }); - const steer = await client.steerPrompt(session.id, queued.prompt_id); - log('steer response', steer); - expect(steer).toEqual({ steered: true, prompt_ids: [queued.prompt_id] }); - - const steerFrame = await steerFramePromise; - const steered = payloadOf(steerFrame); - log('prompt.steered frame', { - frame: frameForLog(steerFrame), - steered, - }); - expect(steered.activePromptId).toBe(active.prompt_id); - expect(steered.promptIds).toEqual([queued.prompt_id]); - - const listedAfter = await client.listPrompts(session.id); - log('prompt list after steer', listedAfter); - expect(listedAfter.queued).toHaveLength(0); - }, - SHORT_TIMEOUT_MS + 30_000, - ); -}); - -function isPromptSteeredFor(sid: string, promptId: string): (frame: AnyFrame) => boolean { - return (frame) => { - if (frame.type !== 'prompt.steered' || frame.session_id !== sid) return false; - const payload = frame.payload as { promptIds?: string[] } | undefined; - return payload?.promptIds?.includes(promptId) === true; - }; -} - -function payloadOf(frame: AnyFrame): T { - expect(frame.payload, `${frame.type} frame should carry payload`).toBeDefined(); - return frame.payload as T; -} - -function frameForLog(frame: AnyFrame): Record { - return { - type: frame.type, - seq: frame.seq, - session_id: frame.session_id, - payload: frame.payload, - }; -} - -async function injectActivePrompt( - sid: string, - body: { prompt_id: string }, -): Promise<{ prompt_id: string }> { - const res = await fetchWithReport( - `${BASE_URL}${API_PREFIX}/debug/prompts/${encodeURIComponent(sid)}/active`, - { - method: 'POST', - headers: { accept: 'application/json', 'content-type': 'application/json' }, - body: JSON.stringify(body), - }, - ); - const envelope = await res.json() as { - code: number; - msg: string; - data: { prompt_id: string }; - }; - expect(envelope.code, envelope.msg).toBe(0); - return envelope.data; -} diff --git a/packages/klient/test/e2e/legacy/refresh-replay.test.ts b/packages/klient/test/e2e/legacy/refresh-replay.test.ts deleted file mode 100644 index 5a001114b2d..00000000000 --- a/packages/klient/test/e2e/legacy/refresh-replay.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Refresh / reload wire-level invariants. - * - * Models the page-refresh path a web client takes when the server is already - * up: hit `/healthz`, `/meta`, `/auth`, then open a fresh WebSocket and replay - * any missed events via `client_hello.cursors` BEFORE pulling REST history - * (REST.md §3 + WS.md §3.2). - * - * What's asserted here (and NOT in `client.test.ts`): - * 1. `/healthz` returns `{ok: true}`. - * 2. `/meta` exposes a non-empty `server_id`. (Since the v2 sync protocol, - * cursors carry a journal `epoch` and seq is durable across restarts — - * a stale cursor is detected server-side via `epoch_changed` instead of - * clients comparing `server_id`.) - * 3. `/auth` returns the `AuthSummary` shape. - * 4. After running one prompt to populate the journal, a fresh WS that - * passes `cursors: { [sid]: { seq: currentSeq } }` is acked with - * `accepted_subscriptions: [sid]`, `resync_required: []`, and NO event - * frames arrive between `server_hello` and the ack (caught-up replay). - * 5. A fresh WS that passes `cursors: { [sid]: { seq: 0 } }` triggers - * replay of every durable event in order (seq 1..N) BEFORE the ack. - * Volatile frames (deltas/progress/status) are never replayed. - * 6. After reconnect, `GET /messages` reflects the persisted state from - * before the WS close. - * - * Live-server gated via the same `daemonReachable()` check as - * `client.test.ts`; missing server → tests skip cleanly so CI stays green. - */ -import { afterEach, describe, expect, it } from 'vitest'; -import { WebSocket as WsWebSocket } from 'ws'; - -import { DaemonClient, WsClient, type AnyFrame } from '../harness/index.js'; -import { fetchWithReport } from '../harness/report.js'; -import { createCaseLogger } from './log.js'; - -const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -const API_PREFIX = '/api/v1'; -const HANDSHAKE_TIMEOUT_MS = 5_000; -const PROMPT_TIMEOUT_MS = 120_000; - -async function daemonReachable(): Promise { - try { - const res = await fetchWithReport(`${BASE_URL}${API_PREFIX}/meta`, { - signal: AbortSignal.timeout(500), - }); - return res.ok; - } catch { - return false; - } -} - -interface Envelope { - code: number; - msg?: string; - data: T; - request_id?: string; -} - -async function getEnvelope( - path: string, - log?: (label: string, value?: unknown) => void, -): Promise { - const res = await fetchWithReport(`${BASE_URL}${API_PREFIX}${path}`, { - headers: { accept: 'application/json' }, - }); - const body = (await res.json()) as Envelope; - log?.('http envelope', { - method: 'GET', - path: `${API_PREFIX}${path}`, - status: res.status, - body, - }); - expect(typeof body.code, `${path} missing envelope.code`).toBe('number'); - expect(body.code, `${path} returned code=${body.code} msg=${body.msg ?? ''}`).toBe(0); - return body.data; -} - -interface HelloResult { - ws: WsClient; - ack: AnyFrame; - replayed: AnyFrame[]; -} - -async function openSocketWithHello(opts: { - sid: string; - lastSeq?: number; - clientId?: string; - log?: (label: string, value?: unknown) => void; -}): Promise { - const wsUrl = `${BASE_URL.replace(/^http/, 'ws')}${API_PREFIX}/ws`; - const ws = new WsClient({ url: wsUrl, wsImpl: WsWebSocket, logger: () => {} }); - opts.log?.('refresh ws open', { url: wsUrl, sid: opts.sid, last_seq: opts.lastSeq }); - await ws.open(); - - const arrivals: AnyFrame[] = []; - ws.onFrame((f) => arrivals.push(f)); - - const serverHello = await ws.waitForFrame((f) => f.type === 'server_hello', HANDSHAKE_TIMEOUT_MS); - opts.log?.('refresh ws server_hello', frameForLog(serverHello)); - - const helloId = `hello-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const payload: Record = { - client_id: opts.clientId ?? `vitest-refresh-${process.pid}`, - subscriptions: [opts.sid], - }; - if (opts.lastSeq !== undefined) { - payload['cursors'] = { [opts.sid]: { seq: opts.lastSeq } }; - } - opts.log?.('refresh ws client_hello', { id: helloId, payload }); - ws.send({ type: 'client_hello', id: helloId, payload }); - - const ack = await ws.waitForFrame( - (f) => f.type === 'ack' && f.id === helloId, - HANDSHAKE_TIMEOUT_MS, - ); - opts.log?.('refresh ws ack', frameForLog(ack)); - - const replayed = arrivals.filter( - (f) => - f.type !== 'server_hello' && - f.type !== 'ack' && - f.type !== 'ping' && - f.type !== 'resync_required' && - f.type !== 'error' && - typeof f.seq === 'number' && - f.session_id === opts.sid && - (opts.lastSeq === undefined || f.seq > opts.lastSeq), - ); - opts.log?.('refresh ws replayed', { - count: replayed.length, - frames: replayed.map(frameForLog), - }); - - return { ws, ack, replayed }; -} - -const reachable = await daemonReachable(); -const describeLive = reachable ? describe : describe.skip; - -const created: Array<{ client: DaemonClient; sid: string }> = []; -const sockets: WsClient[] = []; - -afterEach(async () => { - for (const ws of sockets.splice(0)) { - try { - await ws.close(); - } catch { - // ignore - } - } - for (const { client, sid } of created.splice(0)) { - try { - await client.http.archiveSession(sid); - } catch { - // ignore - } - try { - await client.close(); - } catch { - // ignore - } - } -}); - -describeLive('refresh-replay (live server required)', () => { - it('phase 0: /healthz returns ok:true', async () => { - const log = createCaseLogger('refresh: healthz'); - const health = await getEnvelope<{ ok: boolean }>('/healthz', log); - log('data', health); - expect(health.ok).toBe(true); - }); - - it('phase 0: /meta exposes server_id, version, started_at', async () => { - const log = createCaseLogger('refresh: meta'); - const meta = await getEnvelope<{ - server_id: string; - server_version: string; - started_at: string; - capabilities: Record; - }>('/meta', log); - log('data', meta); - expect(meta.server_id).toMatch(/.+/); - expect(meta.server_version).toMatch(/.+/); - expect(meta.started_at).toMatch(/.+/); - expect(meta.capabilities['websocket']).toBe(true); - }); - - it('phase 0: /auth returns AuthSummary shape', async () => { - const log = createCaseLogger('refresh: auth'); - const auth = await getEnvelope<{ - models_ready: boolean; - providers_count: number; - managed_provider: { name: string; status: string } | null; - }>('/auth', log); - log('data', auth); - expect(typeof auth.models_ready).toBe('boolean'); - expect(typeof auth.providers_count).toBe('number'); - }); - - it( - 'reconnect with caught-up last_seq → ack accepts subscription, no replay events', - async () => { - const log = createCaseLogger('refresh: caught-up replay'); - const client = new DaemonClient({ baseUrl: BASE_URL }); - const session = await client.createSession({ metadata: { cwd: process.cwd() } }); - created.push({ client, sid: session.id }); - log('created session', session); - - await client.connect(); - await client.subscribe(session.id); - log('initial subscribe accepted', { session_id: session.id }); - - let maxSeq = 0; - client.onFrame((f) => { - if ( - typeof f.seq === 'number' && - f.session_id === session.id && - f.seq > maxSeq - ) { - maxSeq = f.seq; - } - }); - - const { finalFrame } = await client.submitAndWait( - session.id, - { content: [{ type: 'text', text: 'Reply with the single word "OK" and nothing else.' }] }, - { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, - ); - log('prompt completed frame', frameForLog(finalFrame)); - if (typeof finalFrame.seq === 'number' && finalFrame.seq > maxSeq) { - maxSeq = finalFrame.seq; - } - log('max seq before reconnect', { session_id: session.id, max_seq: maxSeq }); - expect(maxSeq, 'session must publish at least one event before reconnect').toBeGreaterThan(0); - - await client.close(); - log('closed initial socket'); - - const refreshed = await openSocketWithHello({ sid: session.id, lastSeq: maxSeq, log }); - sockets.push(refreshed.ws); - - expect(refreshed.ack.code).toBe(0); - const payload = (refreshed.ack.payload ?? {}) as { - accepted_subscriptions?: string[]; - resync_required?: string[]; - }; - expect(payload.accepted_subscriptions ?? []).toEqual([session.id]); - expect(payload.resync_required ?? []).toEqual([]); - expect( - refreshed.replayed, - `expected 0 replay events when caught up, got: ${JSON.stringify(refreshed.replayed.map((f) => `${f.type}@${f.seq}`))}`, - ).toHaveLength(0); - log('asserted caught-up replay result', { - accepted_subscriptions: payload.accepted_subscriptions ?? [], - resync_required: payload.resync_required ?? [], - replayed_count: refreshed.replayed.length, - }); - }, - PROMPT_TIMEOUT_MS + 30_000, - ); - - it( - 'reconnect with last_seq=0 → server replays buffered events in order before ack', - async () => { - const log = createCaseLogger('refresh: replay from zero'); - const client = new DaemonClient({ baseUrl: BASE_URL }); - const session = await client.createSession({ metadata: { cwd: process.cwd() } }); - created.push({ client, sid: session.id }); - log('created session', session); - - await client.connect(); - await client.subscribe(session.id); - log('initial subscribe accepted', { session_id: session.id }); - - let maxSeq = 0; - client.onFrame((f) => { - if ( - typeof f.seq === 'number' && - f.session_id === session.id && - f.seq > maxSeq - ) { - maxSeq = f.seq; - } - }); - - const { finalFrame } = await client.submitAndWait( - session.id, - { content: [{ type: 'text', text: 'Reply with the single word "OK" and nothing else.' }] }, - { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, - ); - log('prompt completed frame', frameForLog(finalFrame)); - if (typeof finalFrame.seq === 'number' && finalFrame.seq > maxSeq) { - maxSeq = finalFrame.seq; - } - log('max seq before reconnect', { session_id: session.id, max_seq: maxSeq }); - expect(maxSeq).toBeGreaterThan(0); - - await client.close(); - log('closed initial socket'); - - const refreshed = await openSocketWithHello({ sid: session.id, lastSeq: 0, log }); - sockets.push(refreshed.ws); - - expect(refreshed.ack.code).toBe(0); - const payload = (refreshed.ack.payload ?? {}) as { - accepted_subscriptions?: string[]; - resync_required?: string[]; - }; - expect(payload.accepted_subscriptions ?? []).toEqual([session.id]); - // Buffer cap defaults to 1000; a single prompt emits <<1000 events, so - // every event is still in the ring → no resync_required. - expect(payload.resync_required ?? []).toEqual([]); - expect(refreshed.replayed.length).toBeGreaterThan(0); - - const seqs = refreshed.replayed - .map((f) => f.seq) - .filter((n): n is number => typeof n === 'number'); - expect(Math.min(...seqs)).toBe(1); - expect(Math.max(...seqs)).toBe(maxSeq); - // Daemon must dispatch buffered events in seq order - // (eventService.getBufferedSince filters the buffer in insertion order). - const sorted = seqs.toSorted((a, b) => a - b); - expect(seqs).toEqual(sorted); - log('replay seq assertion', { - min_seq: Math.min(...seqs), - max_seq: Math.max(...seqs), - expected_max_seq: maxSeq, - replayed_count: refreshed.replayed.length, - }); - - // Phase 2: REST snapshot reflects the persisted user + assistant pair. - const { items } = await client.http.listMessages(session.id, { page_size: 100 }); - log('messages snapshot', { - count: items.length, - roles: items.map((m) => m.role), - messages: items, - }); - expect(items.some((m) => m.role === 'user')).toBe(true); - expect(items.some((m) => m.role === 'assistant')).toBe(true); - - // `GET /tasks` returns the documented `{items:[]}` envelope shape. - const tasks = await getEnvelope<{ items: unknown[] }>( - `/sessions/${encodeURIComponent(session.id)}/tasks`, - log, - ); - log('tasks snapshot', tasks); - expect(Array.isArray(tasks.items)).toBe(true); - }, - PROMPT_TIMEOUT_MS + 30_000, - ); -}); - -function frameForLog(frame: AnyFrame): Record { - return { - type: frame.type, - seq: frame.seq, - session_id: frame.session_id, - id: frame.id, - code: frame.code, - msg: frame.msg, - payload: frame.payload, - }; -} diff --git a/packages/klient/test/e2e/legacy/report.test.ts b/packages/klient/test/e2e/legacy/report.test.ts deleted file mode 100644 index 26d4a576425..00000000000 --- a/packages/klient/test/e2e/legacy/report.test.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { EventEmitter } from 'node:events'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, describe, expect, it } from 'vitest'; -import { WebSocket as WsWebSocket } from 'ws'; - -import { - fetchWithReport, - readReportEvents, - recordReportEvent, - resetReportDir, - setActiveReportCase, - writeHtmlReport, -} from '../harness/report.js'; -import { DaemonClient } from '../harness/client.js'; -import { HttpClient } from '../harness/http.js'; -import { WsClient } from '../harness/ws.js'; -import { createCaseLogger } from './log'; - -const tmpDirs: string[] = []; - -afterEach(() => { - for (const dir of tmpDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -function tmpReportDir(): string { - const dir = mkdtempSync(join(tmpdir(), 'server-e2e-report-')); - tmpDirs.push(dir); - return dir; -} - -describe('server-e2e report', () => { - it('renders HTTP and WS trace events into a readable HTML timeline', () => { - const reportDir = tmpReportDir(); - resetReportDir(reportDir); - - recordReportEvent( - { - kind: 'http', - caseName: 'refresh: replay from zero', - method: 'POST', - path: '/sessions', - status: 200, - durationMs: 14, - request: { body: { metadata: { cwd: '/tmp/workspace' } } }, - response: { - envelope: { - code: 0, - msg: 'success', - request_id: 'req_1', - data: { id: 'session_1' }, - }, - }, - }, - { reportDir }, - ); - recordReportEvent( - { - kind: 'ws', - caseName: 'refresh: replay from zero', - direction: 'lifecycle', - message: 'open', - url: 'ws://server.example.test/api/v1/ws', - }, - { reportDir }, - ); - recordReportEvent( - { - kind: 'ws', - caseName: 'refresh: replay from zero', - direction: 'in', - frame: { - type: 'prompt.completed', - seq: 3, - session_id: 'session_1', - payload: { promptId: 'prompt_1' }, - }, - }, - { reportDir }, - ); - recordReportEvent( - { - kind: 'log', - caseName: 'refresh: replay from zero', - label: 'fresh listSessions snapshot', - value: { - count: 20, - sessions: [{ id: 'session_1', title: 'New Session' }], - }, - }, - { reportDir }, - ); - - const htmlPath = writeHtmlReport({ reportDir, title: 'Daemon E2E Report' }); - const html = readFileSync(htmlPath, 'utf8'); - - expect(html).toContain('Daemon E2E Report'); - expect(html).toContain('refresh: replay from zero'); - expect(html).toContain('class="case-nav"'); - expect(html).toContain('class="case-link"'); - expect(html).toContain('Client -> Server'); - expect(html).toContain('Server -> Client'); - expect(html).toContain('class="detail-pane"'); - expect(html).toContain('id="event-scroll"'); - expect(html).toContain('id="detail-scroll"'); - expect(html).toContain('data-event-id="event-0"'); - expect(html).toContain('Step'); - expect(html).toContain('
    #1
    '); - expect(html).toContain('
    #2
    '); - expect(html).not.toContain('
    06:'); - expect(html).toContain('.report { display: grid; grid-template-columns: minmax(680px, 1fr) minmax(320px, 34vw);'); - expect(html).toContain('.lane-head { display: grid; grid-template-columns: 38px minmax(0, 1fr) 22px minmax(0, 1fr);'); - expect(html).toContain('.swim-row { display: grid; grid-template-columns: 38px minmax(0, 1fr) 22px minmax(0, 1fr); align-items: start; min-height: 30px; }'); - expect(html).toContain('border-radius: 0'); - expect(html).toContain('
  • fresh listSessions snapshot'); - expect(html).not.toContain(''); - expect(html).toContain('session_1'); - expect(html).toContain('scrollIntoView'); - expect(html).toContain('function setActiveEvent(eventId, options = {})'); - expect(html).toContain('if (!options.scrollPeer) return;'); - expect(html).toContain("setActiveEvent(nearest(rows, eventScroll)?.dataset.eventId, { scrollPeer: false });"); - expect(html).toContain("setActiveEvent(nearest(details, detailScroll)?.dataset.eventId, { scrollPeer: false });"); - expect(html).toContain("row.addEventListener('click', () => setActiveEvent(row.dataset.eventId, { scrollPeer: true, peer: 'detail' }));"); - expect(html).toContain("detail.addEventListener('click', () => setActiveEvent(detail.dataset.eventId, { scrollPeer: true, peer: 'timeline' }));"); - expect(html).not.toContain('syncing'); - expect(html).not.toContain("if (source === 'timeline')"); - expect(html).not.toContain("if (source === 'detail')"); - expect(html).not.toContain('WS -- open'); - expect(html).not.toMatch(/