diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts new file mode 100644 index 000000000..8f111dd1c --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -0,0 +1,317 @@ +/** + * `contextMemory` transcript reducer — rebuilds the FULL message history of an + * agent from its `context.*` wire records for UI display (snapshot / messages). + * + * The live `ContextModel` (`ContextMemoryService`) rewrites the model-facing + * context on `context.apply_compaction` into `[...keptUserMessages, + * compaction_summary]`, so reading the live context after a compaction loses + * everything before the fold. The wire log keeps every record, though, so this + * reducer re-reduces the `context.*` records with the same semantics as the + * live `ContextMemory` restore, EXCEPT that `context.apply_compaction` KEEPS + * the full history and appends a user-role summary marker — the same view the + * v1 transcript / TUI shows after resume. `foldedLength` tracks what the live + * (folded) `context.history.length` would be, so a caller can detect and + * append an unflushed live tail. + * + * Mirrors v1 `reduceWireRecords` + * (`packages/agent-core/src/services/message/transcript.ts`): + * - `context.append_message` → append (deferred while a tool exchange is open) + * - `context.append_loop_event` → step.begin/content.part/tool.call mutate the + * open assistant; tool.result appends a tool + * message with the raw output + * - `context.apply_compaction` → keep the full history, append the user-role + * summary marker, recover `foldedLength` from + * the recorded kept-count fields + * - `context.undo` → remove tail messages (skip injections, stop + * at compaction summaries / clear floor) + * - `context.clear` → keep prior transcript entries but reset the + * folded view + * - `context.splice` → array splice (legacy 1.5 / internal) + */ + +import { type ContentPart, type ToolCall } from '#/app/llmProtocol/message'; +import type { PersistedRecord } from '#/wire/wireService'; + +import { + COMPACT_USER_MESSAGE_MAX_TOKENS, + collectCompactableUserMessages, + isRealUserInput, + selectRecentUserMessages, +} from './compactionHandoff'; +import type { LoopRecordedEvent } from './loopEventFold'; +import type { ContextMessage } from './types'; + +const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = + 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.'; + +export interface ContextTranscript { + /** Full message history, compacted prefixes included. */ + readonly entries: readonly ContextMessage[]; + /** Length the live (folded) `context.history` would have after these records. */ + readonly foldedLength: number; +} + +interface MutableMessage { + id?: string; + role: ContextMessage['role']; + content: ContentPart[]; + toolCalls: ToolCall[]; + toolCallId?: string; + isError?: boolean; + origin?: ContextMessage['origin']; +} + +interface MutableEntry { + message: MutableMessage; +} + +/** Reduce `context.*` wire records into the full transcript. Pure (no I/O). */ +export function reduceContextTranscript(records: Iterable): ContextTranscript { + const transcript: MutableEntry[] = []; + /** What `context.history.length` would be right now (post-folding). */ + let foldedLength = 0; + /** Transcript index `context.undo` may not cross (set by `context.clear`). */ + let clearFloor = 0; + const openSteps = new Map(); + const pendingToolResultIds = new Set(); + let deferred: MutableEntry[] = []; + + const push = (...entries: MutableEntry[]): void => { + transcript.push(...entries); + foldedLength += entries.length; + }; + const flushDeferredIfToolExchangeClosed = (): void => { + if (pendingToolResultIds.size > 0 || deferred.length === 0) return; + push(...deferred); + deferred = []; + }; + const closePendingToolResults = (): void => { + if (pendingToolResultIds.size === 0) return; + const interruptedToolCallIds = [...pendingToolResultIds]; + for (const toolCallId of interruptedToolCallIds) { + push({ + message: { + role: 'tool', + content: [{ type: 'text', text: TOOL_INTERRUPTED_ON_RESUME_OUTPUT }], + toolCalls: [], + toolCallId, + isError: true, + }, + }); + pendingToolResultIds.delete(toolCallId); + } + flushDeferredIfToolExchangeClosed(); + }; + const resetOpenState = (): void => { + openSteps.clear(); + pendingToolResultIds.clear(); + deferred = []; + }; + + const applyLoopEvent = (event: LoopRecordedEvent): void => { + switch (event.type) { + case 'step.begin': { + closePendingToolResults(); + const entry: MutableEntry = { + message: { role: 'assistant', content: [], toolCalls: [] }, + }; + push(entry); + openSteps.set(event.uuid, entry); + return; + } + case 'step.end': { + openSteps.delete(event.uuid); + flushDeferredIfToolExchangeClosed(); + return; + } + case 'content.part': { + // Lenient where the live reducer throws: a dangling part in a damaged + // file should not take the whole transcript down. + openSteps.get(event.stepUuid)?.message.content.push(event.part); + return; + } + case 'tool.call': { + const openStep = openSteps.get(event.stepUuid); + if (openStep === undefined) return; + const call: ToolCall = { + type: 'function', + id: event.toolCallId, + name: event.name, + arguments: event.args === undefined ? null : JSON.stringify(event.args), + ...(event.extras !== undefined ? { extras: event.extras } : {}), + }; + openStep.message.toolCalls.push(call); + pendingToolResultIds.add(event.toolCallId); + return; + } + case 'tool.result': { + if (!pendingToolResultIds.has(event.toolCallId)) return; + push({ + message: { + role: 'tool', + content: rawToolResultContent(event.result.output), + toolCalls: [], + toolCallId: event.toolCallId, + isError: event.result.isError, + }, + }); + pendingToolResultIds.delete(event.toolCallId); + flushDeferredIfToolExchangeClosed(); + return; + } + } + }; + + const applyUndo = (count: number): void => { + if (count <= 0) return; + let removedUserCount = 0; + for (let i = transcript.length - 1; i >= clearFloor; i--) { + const message = transcript[i]!.message; + if (message.origin?.kind === 'injection') continue; + if (message.origin?.kind === 'compaction_summary') break; + transcript.splice(i, 1); + foldedLength = Math.max(0, foldedLength - 1); + if (isRealUserInput(message)) { + removedUserCount++; + if (removedUserCount >= count) break; + } + } + resetOpenState(); + }; + + for (const record of records) { + switch (record.type) { + case 'context.append_message': { + const message = record['message'] as ContextMessage; + const entry: MutableEntry = { + message: { + ...(message.id !== undefined ? { id: message.id } : {}), + role: message.role, + content: [...message.content], + toolCalls: [...message.toolCalls], + ...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}), + ...(message.isError !== undefined ? { isError: message.isError } : {}), + ...(message.origin !== undefined ? { origin: message.origin } : {}), + }, + }; + if (pendingToolResultIds.size > 0) deferred.push(entry); + else push(entry); + break; + } + case 'context.append_loop_event': + applyLoopEvent(record['event'] as LoopRecordedEvent); + break; + case 'context.splice': { + const start = record['start'] as number; + const deleteCount = record['deleteCount'] as number; + const messages = record['messages'] as readonly ContextMessage[]; + if (deleteCount === 0 && messages.length === 0) break; + // The transcript is append-only (matches the TUI / replay-builder + // transcript): a splice's inserted messages land at the tail and the + // deleted messages stay for display, so legacy compaction-via-splice + // keeps the pre-compaction prefix. `foldedLength` tracks the literal + // live-context length after the splice. + for (const message of messages) transcript.push(toMutableEntry(message)); + const clampedStart = Math.min(Math.max(start, 0), foldedLength); + const removed = Math.min(deleteCount, foldedLength - clampedStart); + foldedLength = foldedLength - removed + messages.length; + resetOpenState(); + break; + } + case 'context.apply_compaction': { + // The live context folds into `[...keptUserMessages, summary]`; the + // transcript keeps the full history and appends the summary marker. + transcript.push({ + message: { + role: 'user', + content: [{ type: 'text', text: readCompactionSummaryText(record) }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }, + }); + const keptUserMessageCount = readNumber(record, 'keptUserMessageCount'); + const keptHeadUserMessageCount = readNumber(record, 'keptHeadUserMessageCount'); + const compactedCount = readNumber(record, 'compactedCount'); + if (keptUserMessageCount !== undefined) { + // +1 for the summary message; +1 more when the selection split into + // head + tail (the live context then also holds an elision marker). + foldedLength = keptUserMessageCount + (keptHeadUserMessageCount === undefined ? 1 : 2); + } else if (compactedCount !== undefined && compactedCount < foldedLength) { + // Legacy record that kept `history.slice(compactedCount)` verbatim. + foldedLength = 1 + (foldedLength - compactedCount); + } else { + // Legacy record covering the whole live history: re-derive from the + // post-clear transcript only (the live context rebuilds from the + // post-`/clear` messages). + const keptUserMessages = selectRecentUserMessages( + collectCompactableUserMessages(transcript.slice(clearFloor).map((e) => e.message)), + COMPACT_USER_MESSAGE_MAX_TOKENS, + ); + foldedLength = keptUserMessages.length + 1; + } + resetOpenState(); + break; + } + case 'context.undo': + applyUndo(record['count'] as number); + break; + case 'context.clear': + clearFloor = transcript.length; + foldedLength = 0; + resetOpenState(); + break; + default: + break; + } + } + + return { entries: transcript.map((e) => e.message), foldedLength }; +} + +function toMutableEntry(message: ContextMessage): MutableEntry { + return { + message: { + ...(message.id !== undefined ? { id: message.id } : {}), + role: message.role, + content: [...message.content], + toolCalls: [...message.toolCalls], + ...(message.toolCallId !== undefined ? { toolCallId: message.toolCallId } : {}), + ...(message.isError !== undefined ? { isError: message.isError } : {}), + ...(message.origin !== undefined ? { origin: message.origin } : {}), + }, + }; +} + +function readCompactionSummaryText(record: PersistedRecord): string { + const summary = record['summary']; + if (typeof summary === 'string') return summary; + const contextSummary = record['contextSummary']; + if (typeof contextSummary === 'string') return contextSummary; + // Legacy record whose `summary` is a whole ContextMessage — flatten its text. + if (isContextMessageLike(summary)) return textOfParts(summary.content); + return ''; +} + +function isContextMessageLike(value: unknown): value is ContextMessage { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const message = value as { role?: unknown; content?: unknown }; + return typeof message.role === 'string' && Array.isArray(message.content); +} + +function textOfParts(content: readonly ContentPart[]): string { + let text = ''; + for (const part of content) { + if (part.type === 'text') text += part.text; + } + return text; +} + +function readNumber(record: PersistedRecord, key: string): number | undefined { + const value = record[key]; + return typeof value === 'number' ? value : undefined; +} + +/** Raw output verbatim — status text is added only at LLM projection, never in the transcript. */ +function rawToolResultContent(output: string | readonly ContentPart[]): ContentPart[] { + return typeof output === 'string' ? [{ type: 'text', text: output }] : [...output]; +} diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts index c035ca587..0780c07ea 100644 --- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts +++ b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts @@ -3,25 +3,44 @@ * * Stateless App-scope dispatcher: each call resolves the target session (and * its main agent), sources the transcript, and projects it into the v1 wire - * shape. Both live and cold sessions are read from the main agent's - * `IAgentContextMemoryService`: live sessions already hold the folded history in - * memory, and cold sessions are resumed (restoring the main agent's wire log and - * replaying it into the `ContextModel`) before the read, so the same `get()` - * yields the full transcript. Pagination, id derivation, and the role filter - * mirror v1's `MessageService` + * shape. + * + * History source is the main agent's `wire.jsonl` record log, NOT the live + * `IAgentContextMemoryService.get()`: that live history is the model's CURRENT + * context — after a compaction it collapses into `[...keptUserMessages, + * compaction_summary]`, which made `GET /sessions/{sid}/messages` lose + * everything before the fold. The wire log keeps every record, so + * `reduceContextTranscript` rebuilds the full transcript (compaction inserts a + * summary marker instead of dropping the prefix) — the same view v1's + * `MessageService` serves. Records reach disk through an async flush queue, so + * a request on a live session may find the wire a few records behind memory: + * `foldedLength` is what the live history length WOULD be from the file's + * records, and anything beyond it in the real live context is appended as the + * unflushed tail. Pagination, id derivation, and the role filter mirror v1's + * `MessageService` * (`packages/agent-core/src/services/message/messageService.ts`). */ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + import type { Message, PageResponse } from '@moonshot-ai/protocol'; import { InstantiationType } from '#/_base/di/extensions'; -import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; +import { type ISessionScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ensureMainAgent, MAIN_AGENT_ID } from '#/session/agentLifecycle/mainAgent'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { + reduceContextTranscript, + type ContextTranscript, +} from '#/agent/contextMemory/contextTranscript'; import { toProtocolMessage } from '#/agent/contextMemory/messageProjection'; +import type { ContextMessage } from '#/agent/contextMemory/types'; import { ErrorCodes, KimiError } from '#/errors'; import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import type { PersistedRecord } from '#/wire/wireService'; import { IMessageLegacyService, type MessageListQuery } from './messageLegacy'; @@ -97,33 +116,85 @@ export class MessageLegacyService implements IMessageLegacyService { throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } - const agent = await this.resolveMainAgent(sessionId); - if (agent === undefined) return []; - - // The transcript is the main agent's `ContextModel`: live sessions already - // hold it in memory, and cold sessions have been resumed (wire log restored - // + replayed into the Model) by `resolveMainAgent` before we get here, so a - // single `get()` covers both. The legacy replay read model - // (`IAgentRecordService.buildReplay`) is empty on every path and is gone. - const source = agent.accessor.get(IAgentContextMemoryService).get(); - - return source.map((msg, index) => toProtocolMessage(sessionId, index, msg, summary.createdAt)); - } - - /** - * Resolve the session's main agent, loading + restoring it from the persisted - * wire log when the session is cold (delegated to `ISessionLifecycleService.resume`). - * Returns `undefined` only when a cold session's workspace is gone and the - * session directory cannot be reconstructed (mirrors the `fork` limitation). - */ - private async resolveMainAgent(sessionId: string): Promise { const session = await this.lifecycle.resume(sessionId); - if (session === undefined) return undefined; - // Live session whose main agent has not been materialized yet: create it - // fresh. No restore here — the session was already live, so any persisted - // wire is already reflected in memory; re-restoring would re-apply splices. - return ensureMainAgent(session); + if (session === undefined) return []; + // Materialize the main agent so the live context is available for the + // unflushed-tail merge below. `resume` already restored + replayed the + // wire for a cold session; a live session is already current. + const agent = await ensureMainAgent(session); + + // Read the wire file BEFORE the live context so the in-memory history is + // always at least as new as the file snapshot and the tail merge can only + // append (mirrors v1 `MessageService`). + const transcript = await this.readTranscript(session); + const contextMessages = agent.accessor.get(IAgentContextMemoryService).get(); + const entries = mergeLiveTail(transcript, contextMessages); + + return entries.map((msg, index) => toProtocolMessage(sessionId, index, msg, summary.createdAt)); } + + /** Reduce the main agent's persisted wire log into the full transcript. */ + private async readTranscript(session: ISessionScopeHandle): Promise { + const ctx = session.accessor.get(ISessionContext); + const wirePath = join(ctx.sessionDir, 'agents', MAIN_AGENT_ID, 'wire.jsonl'); + const records = await readWireRecords(wirePath); + return reduceContextTranscript(records); + } +} + +/** + * Append the unflushed live tail: when the in-memory (folded) context is + * longer than the wire-derived `foldedLength`, the surplus is records that + * have not reached disk yet and must be appended so a read on a live session + * does not trail memory. + */ +function mergeLiveTail( + transcript: ContextTranscript, + contextMessages: readonly ContextMessage[], +): readonly ContextMessage[] { + if (contextMessages.length <= transcript.foldedLength) return transcript.entries; + return [...transcript.entries, ...contextMessages.slice(transcript.foldedLength)]; +} + +/** + * Parse a `wire.jsonl` file. A torn final line (crash mid-flush) is dropped; + * corruption anywhere else throws. A missing file yields an empty record list + * (a brand-new session whose context has not been flushed yet). + */ +async function readWireRecords(wirePath: string): Promise { + let raw: string; + try { + raw = await readFile(wirePath, 'utf8'); + } catch (error) { + if (isEnoent(error)) return []; + throw error; + } + const lines = raw.split('\n'); + const records: PersistedRecord[] = []; + 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 PersistedRecord); + } 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; +} + +function isEnoent(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); } registerScopedService( diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index f76c2bb59..f8f2a6556 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -82,7 +82,7 @@ export class SessionLegacyService implements ISessionLegacyService { sessionId: string, body: UpdateSessionProfileRequest, ): Promise { - const session = this.lifecycle.get(sessionId); + const session = await this.lifecycle.resume(sessionId); if (session === undefined) { throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } @@ -274,7 +274,10 @@ export class SessionLegacyService implements ISessionLegacyService { // Native `ISessionLifecycleService.archive` is a no-op for sessions that // are not live, so gate on the live handle (matches the previous route // behaviour): a missing live session is reported as `session.not_found`. - if (this.lifecycle.get(sessionId) === undefined) { + // `resume` (not `get`) so archiving a freshly-opened cold session still + // works; `resume` returns undefined only when the session is unknown or its + // workspace is gone, which is reported as `session.not_found`. + if ((await this.lifecycle.resume(sessionId)) === undefined) { throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } await this.lifecycle.archive(sessionId); @@ -315,11 +318,6 @@ export class SessionLegacyService implements ISessionLegacyService { }; } - /** - * Resolve the session's main agent, creating it on demand (mirrors v1's - * `resumeSession`; delegates to the `agentLifecycle` domain's - * `ensureMainAgent` bootstrap helper). - */ /** * Apply the v1 `agent_config` patch onto the main agent. Mirrors v1's * `IPromptService.applyAgentState` (`promptService.ts:650-743`) in both order @@ -383,7 +381,13 @@ export class SessionLegacyService implements ISessionLegacyService { } private async resolveMainAgent(sessionId: string): Promise { - const session = this.lifecycle.get(sessionId); + // `resume` (not `get`) so a persisted-but-cold session — freshly opened in + // the web UI before any prompt, or created by a previous process — is loaded + // from disk instead of being reported as `session.not_found`. Mirrors v1's + // `SessionService.undo`/`compact`, which call `resumeSession` first; `resume` + // returns `undefined` only when the session is unknown or its workspace is + // gone, so a genuinely missing session still 404s. + const session = await this.lifecycle.resume(sessionId); if (session === undefined) { throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 4cdeb10f7..000106746 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -302,6 +302,7 @@ export * from '#/agent/contextMemory/compactionHandoff'; export * from '#/agent/contextMemory/loopEventFold'; export * from '#/agent/contextMemory/messageId'; export * from '#/agent/contextMemory/messageProjection'; +export * from '#/agent/contextMemory/contextTranscript'; export * from '#/agent/contextMemory/types'; export * from '#/agent/systemReminder/systemReminder'; export * from '#/agent/systemReminder/systemReminderService'; diff --git a/packages/agent-core-v2/test/contextMemory/transcript.test.ts b/packages/agent-core-v2/test/contextMemory/transcript.test.ts new file mode 100644 index 000000000..bc1252bce --- /dev/null +++ b/packages/agent-core-v2/test/contextMemory/transcript.test.ts @@ -0,0 +1,212 @@ +/** + * Tests for `reduceContextTranscript` — the wire-transcript reducer used by the + * snapshot and messages endpoints. Mirrors v1 `reduceWireRecords` expectations: + * compaction keeps the prefix and appends a summary marker; undo removes the + * tail but stops at compaction summaries / clear floors; clear keeps the + * transcript but resets the folded view. + */ + +import { describe, expect, it } from 'vitest'; + +import { + reduceContextTranscript, + type ContextTranscript, +} from '../../src/agent/contextMemory/contextTranscript'; +import type { LoopRecordedEvent } from '../../src/agent/contextMemory/loopEventFold'; +import type { ContextMessage, PromptOrigin } from '../../src/agent/contextMemory/types'; +import type { PersistedRecord } from '../../src/wire/wireService'; + +function userMessage(text: string, origin?: PromptOrigin): ContextMessage { + return { + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + ...(origin === undefined ? {} : { origin }), + }; +} + +function assistantMessage(text: string): ContextMessage { + return { role: 'assistant', content: [{ type: 'text', text }], toolCalls: [] }; +} + +function appendMessage(message: ContextMessage): PersistedRecord { + return { type: 'context.append_message', message }; +} + +function loopEvent(event: LoopRecordedEvent): PersistedRecord { + return { type: 'context.append_loop_event', event }; +} + +function assistantStep(uuid: string, text: string): PersistedRecord[] { + return [ + loopEvent({ type: 'step.begin', uuid }), + loopEvent({ type: 'content.part', stepUuid: uuid, part: { type: 'text', text } }), + loopEvent({ type: 'step.end', uuid }), + ]; +} + +function compaction( + summary: string, + compactedCount: number, + keptUserMessageCount?: number, + keptHeadUserMessageCount?: number, +): PersistedRecord { + return { + type: 'context.apply_compaction', + summary, + contextSummary: `prefixed ${summary}`, + compactedCount, + tokensBefore: 1000, + tokensAfter: 100, + ...(keptUserMessageCount === undefined ? {} : { keptUserMessageCount }), + ...(keptHeadUserMessageCount === undefined ? {} : { keptHeadUserMessageCount }), + }; +} + +function undo(count: number): PersistedRecord { + return { type: 'context.undo', count }; +} + +function texts(result: ContextTranscript): string[] { + return result.entries.map((m) => + m.content.map((p) => (p.type === 'text' ? p.text : `[${p.type}]`)).join(''), + ); +} + +describe('reduceContextTranscript', () => { + it('builds the transcript from append_message and loop events', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('u1')), + ...assistantStep('s1', 'a1'), + ]); + expect(texts(result)).toEqual(['u1', 'a1']); + expect(result.entries.map((m) => m.role)).toEqual(['user', 'assistant']); + expect(result.foldedLength).toBe(2); + }); + + it('compaction keeps the prefix and appends a user-role summary marker', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('u1')), + ...assistantStep('s1', 'a1'), + appendMessage(userMessage('u2')), + ...assistantStep('s2', 'a2'), + compaction('SUM', 4), + appendMessage(userMessage('u3')), + ]); + expect(texts(result)).toEqual(['u1', 'a1', 'u2', 'a2', 'SUM', 'u3']); + expect(result.entries[4]!.origin).toEqual({ kind: 'compaction_summary' }); + expect(result.entries[4]!.role).toBe('user'); + // live folded view would be [u1, u2, SUM, u3] + expect(result.foldedLength).toBe(4); + }); + + it('uses the recorded kept-user count for foldedLength when present', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('u1')), + appendMessage(userMessage('u2')), + appendMessage(userMessage('u3')), + compaction('SUM', 3, 1), + appendMessage(userMessage('u4')), + ]); + // 1 kept user message + summary + u4 appended after compaction. + expect(result.foldedLength).toBe(3); + }); + + it('accounts for the elision marker when the record kept a head segment', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('u1')), + appendMessage(userMessage('u2')), + ...assistantStep('s1', 'a1'), + compaction('SUM', 3, 2, 1), + ]); + // Live context: head user + elision marker + tail user + summary. + expect(result.foldedLength).toBe(4); + }); + + it('preserves the pre-compaction assistant reply after a later undo', () => { + // The reported regression: send A, /compact, send B, undo. The snapshot + // must still show A's assistant reply (compaction only folds the live + // context; the transcript keeps the full history). + const result = reduceContextTranscript([ + appendMessage(userMessage('message A')), + appendMessage(assistantMessage('reply A')), + compaction('summary text', 2, 1), + appendMessage(userMessage('message B')), + appendMessage(assistantMessage('reply B')), + undo(1), + ]); + expect(texts(result)).toEqual(['message A', 'reply A', 'summary text']); + expect(result.entries.map((m) => m.role)).toEqual(['user', 'assistant', 'user']); + expect(result.foldedLength).toBe(2); + }); + + it('undo without compaction keeps the earlier exchange intact', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('message A')), + appendMessage(assistantMessage('reply A')), + appendMessage(userMessage('message B')), + appendMessage(assistantMessage('reply B')), + undo(1), + ]); + expect(texts(result)).toEqual(['message A', 'reply A']); + }); + + it('undo stops at a compaction summary', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('old')), + compaction('SUM', 1, 1), + appendMessage(userMessage('recent')), + appendMessage(assistantMessage('answer')), + undo(2), + ]); + // Only the post-compaction exchange is removed; the summary blocks further undo. + expect(texts(result)).toEqual(['old', 'SUM']); + }); + + it('clear keeps prior transcript entries but resets the folded view', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('u1')), + appendMessage(userMessage('u2')), + { type: 'context.clear' }, + appendMessage(userMessage('u3')), + ]); + expect(texts(result)).toEqual(['u1', 'u2', 'u3']); + expect(result.foldedLength).toBe(1); + }); + + it('undo does not cross a clear floor', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('u1')), + { type: 'context.clear' }, + appendMessage(userMessage('u2')), + appendMessage(assistantMessage('a2')), + undo(1), + ]); + // The post-clear exchange (u2 + a2) is removed; pre-clear u1 stays in the + // transcript and the clear floor blocks undo from reaching it. + expect(texts(result)).toEqual(['u1']); + expect(result.foldedLength).toBe(0); + }); + + it('folds tool calls and results from loop events', () => { + const result = reduceContextTranscript([ + appendMessage(userMessage('q')), + loopEvent({ type: 'step.begin', uuid: 's1' }), + loopEvent({ type: 'content.part', stepUuid: 's1', part: { type: 'text', text: 'hi' } }), + loopEvent({ + type: 'tool.call', + stepUuid: 's1', + toolCallId: 'call_1', + name: 'Bash', + args: { command: 'echo hi' }, + }), + loopEvent({ type: 'tool.result', toolCallId: 'call_1', result: { output: 'hi' } }), + loopEvent({ type: 'step.end', uuid: 's1' }), + ]); + expect(result.entries.map((m) => m.role)).toEqual(['user', 'assistant', 'tool']); + expect(result.entries[1]!.toolCalls).toHaveLength(1); + expect(result.entries[1]!.toolCalls[0]!.id).toBe('call_1'); + expect(result.entries[2]!.toolCallId).toBe('call_1'); + expect(result.foldedLength).toBe(3); + }); +}); diff --git a/packages/kap-server/src/routes/approvals.ts b/packages/kap-server/src/routes/approvals.ts index 5b85d69fd..0863c77d7 100644 --- a/packages/kap-server/src/routes/approvals.ts +++ b/packages/kap-server/src/routes/approvals.ts @@ -100,7 +100,7 @@ export function registerApprovalsRoutes(app: ApprovalRouteHost, core: Scope): vo }, async (req, reply) => { const { session_id } = req.params; - const handle = core.accessor.get(ISessionLifecycleService).get(session_id); + const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); if (handle === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), @@ -134,7 +134,7 @@ export function registerApprovalsRoutes(app: ApprovalRouteHost, core: Scope): vo }, async (req, reply) => { const { session_id, approval_id } = req.params; - const handle = core.accessor.get(ISessionLifecycleService).get(session_id); + const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); if (handle === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), diff --git a/packages/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index 1a1db3385..007c57c0b 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -145,6 +145,18 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { } const fsAction = action as FsAction; + // Cold-load a persisted-but-not-live session so fs actions (which only + // need the work dir) do not 404 on a freshly-opened session. Matches v1, + // which reads the persisted cwd. `resume` returns undefined only when the + // session is unknown or its workspace is gone. + const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + if (session === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + try { switch (fsAction) { case 'list': @@ -232,6 +244,16 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { return; } + // Cold-load so a freshly-opened (persisted but not live) session can still + // serve downloads; `resume` only returns undefined for unknown / workspace-gone. + const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + if (session === undefined) { + reply.send( + errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), + ); + return; + } + let resolved: Awaited>; try { resolved = await resolveFs(core, session_id).resolveDownload(relPath); diff --git a/packages/kap-server/src/routes/questions.ts b/packages/kap-server/src/routes/questions.ts index f48cd717a..9d4b6e924 100644 --- a/packages/kap-server/src/routes/questions.ts +++ b/packages/kap-server/src/routes/questions.ts @@ -124,7 +124,7 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo }, async (req, reply) => { const { session_id } = req.params; - const handle = core.accessor.get(ISessionLifecycleService).get(session_id); + const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); if (handle === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), @@ -173,7 +173,7 @@ export function registerQuestionsRoutes(app: QuestionRouteHost, core: Scope): vo const questionId = parsed.id; const action: 'resolve' | 'dismiss' = parsed.kind === 'bare' ? 'resolve' : parsed.action; - const handle = core.accessor.get(ISessionLifecycleService).get(session_id); + const handle = await core.accessor.get(ISessionLifecycleService).resume(session_id); if (handle === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 1615de535..7d0bafb3c 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -622,7 +622,9 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void } if (parsed.action === 'btw') { - const session = core.accessor.get(ISessionLifecycleService).get(parsed.id); + // `resume` (not `get`) so a freshly-opened cold session can start a + // side-channel agent; matches v1's `startBtw` which resumes first. + const session = await core.accessor.get(ISessionLifecycleService).resume(parsed.id); if (session === undefined) { throw new KimiError( ErrorCodes.SESSION_NOT_FOUND, @@ -772,7 +774,9 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void }, async (req, reply) => { const { session_id } = req.params; - const session = core.accessor.get(ISessionLifecycleService).get(session_id); + // `resume` (not `get`) so a freshly-opened cold session still computes its + // warnings; matches v1's best-effort `resumeSession` before reading them. + const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); if (session === undefined) { reply.send( errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id), diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts index 720c2c506..c3387d36c 100644 --- a/packages/kap-server/src/routes/skills.ts +++ b/packages/kap-server/src/routes/skills.ts @@ -141,7 +141,11 @@ async function resolveActivatedSession( sessionId: string, requestId: string, ): Promise { - const handle = core.accessor.get(ISessionLifecycleService).get(sessionId); + // `resume` (not `get`) so listing/activating skills on a freshly-opened cold + // session cold-loads it instead of reporting "not activated"; matches v1's + // `resumeSession` in SkillService. `resume` returns undefined only when the + // session is unknown or its workspace is gone. + const handle = await core.accessor.get(ISessionLifecycleService).resume(sessionId); if (handle !== undefined) return { handle }; const summary = await core.accessor.get(ISessionIndex).get(sessionId); diff --git a/packages/kap-server/src/routes/terminals.ts b/packages/kap-server/src/routes/terminals.ts index 83b1c7b24..c5e695f98 100644 --- a/packages/kap-server/src/routes/terminals.ts +++ b/packages/kap-server/src/routes/terminals.ts @@ -77,12 +77,13 @@ const sessionAndTailParamSchema = z.object({ const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); /** - * Resolve the session's `ISessionTerminalService` from the URL session id. Throws a - * coded `session.not_found` when the session is not live — terminals are - * inherently live PTY state, so a persisted-but-not-live session is `40401`. + * Resolve the session's `ISessionTerminalService` from the URL session id, + * cold-loading a persisted-but-not-live session first (matches v1, which spawns + * from the persisted cwd). Throws `session.not_found` only when the session is + * unknown or its workspace is gone. */ -function resolveTerminal(core: Scope, sessionId: string): ISessionTerminalService { - const session = core.accessor.get(ISessionLifecycleService).get(sessionId); +async function resolveTerminal(core: Scope, sessionId: string): Promise { + const session = await core.accessor.get(ISessionLifecycleService).resume(sessionId); if (session === undefined) { throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); } @@ -106,7 +107,7 @@ export function registerTerminalsRoutes(app: TerminalsRouteHost, core: Scope): v async (req, reply) => { try { const { session_id } = req.params; - const items = await resolveTerminal(core, session_id).list(); + const items = await (await resolveTerminal(core, session_id)).list(); reply.send(okEnvelope({ items }, req.id)); } catch (err) { sendMappedError(reply, req.id, err); @@ -137,7 +138,7 @@ export function registerTerminalsRoutes(app: TerminalsRouteHost, core: Scope): v async (req, reply) => { try { const { session_id } = req.params; - const terminal = await resolveTerminal(core, session_id).create(req.body); + const terminal = await (await resolveTerminal(core, session_id)).create(req.body); reply.send(okEnvelope(terminal, req.id)); } catch (err) { sendMappedError(reply, req.id, err); @@ -167,7 +168,7 @@ export function registerTerminalsRoutes(app: TerminalsRouteHost, core: Scope): v async (req, reply) => { try { const { session_id, terminal_id } = req.params; - const terminal = await resolveTerminal(core, session_id).get(terminal_id); + const terminal = await (await resolveTerminal(core, session_id)).get(terminal_id); reply.send(okEnvelope(terminal, req.id)); } catch (err) { sendMappedError(reply, req.id, err); @@ -209,7 +210,7 @@ export function registerTerminalsRoutes(app: TerminalsRouteHost, core: Scope): v reply.send(errEnvelope(ErrorCode.VALIDATION_FAILED, message, req.id)); return; } - const result = await resolveTerminal(core, session_id).close(parsed.id); + const result = await (await resolveTerminal(core, session_id)).close(parsed.id); reply.send(okEnvelope(result, req.id)); } catch (err) { sendMappedError(reply, req.id, err); diff --git a/packages/kap-server/src/services/snapshot/index.ts b/packages/kap-server/src/services/snapshot/index.ts index 9f25a48a2..8447bc4c3 100644 --- a/packages/kap-server/src/services/snapshot/index.ts +++ b/packages/kap-server/src/services/snapshot/index.ts @@ -2,7 +2,6 @@ export type { ISnapshotReader } from './snapshot'; export { SnapshotNotFoundError, SnapshotTimeoutError } from './snapshot'; export { SnapshotReader, - reduceContextRecords, readWireRecords, type SnapshotReaderDeps, type SnapshotReaderLogger, diff --git a/packages/kap-server/src/services/snapshot/snapshotReader.ts b/packages/kap-server/src/services/snapshot/snapshotReader.ts index 24b8fce77..f5a331488 100644 --- a/packages/kap-server/src/services/snapshot/snapshotReader.ts +++ b/packages/kap-server/src/services/snapshot/snapshotReader.ts @@ -5,11 +5,13 @@ * Reads `/sessions///state.json` and * `…/agents/main/wire.jsonl` directly, bypassing * `ISessionLifecycleService.resume` (DI-scope materialization, MCP connect, - * full wire replay). The transcript is reduced from the folded `context.*` - * records with the exact `contextOps` apply semantics, so the result is - * byte-identical to the live `IAgentContextMemoryService.get()` view used by - * the `legacy` (resume) path. `(size, mtimeMs)` transcript cache and the - * watermark both come from in-memory state, keeping warm reads sub-ms. + * full wire replay). The transcript is reduced from the `context.*` records + * with `reduceContextTranscript`, which mirrors the live reducers EXCEPT that + * `context.apply_compaction` keeps the full history and appends a summary + * marker instead of dropping the compacted prefix — the same full-transcript + * view v1 serves (so compacted-away assistant replies stay visible after a + * later undo). `(size, mtimeMs)` transcript cache and the watermark both come + * from in-memory state, keeping warm reads sub-ms. * * Pending approvals/questions, the live status, and `current_prompt_id` are * only available while the session is live; for a cold session they correctly @@ -20,9 +22,6 @@ import { readFile, stat as fsStat } from 'node:fs/promises'; import { join } from 'node:path'; import { - computeUndoCut, - foldAppendMessage, - foldLoopEvent, IAgentLifecycleService, IAgentPromptLegacyService, ISessionActivity, @@ -31,11 +30,9 @@ import { ISessionLifecycleService, IWorkspaceRegistry, normalizeSessionMeta, - applyContextCompactionRecord, - resetFold, + reduceContextTranscript, toProtocolMessage, type ContextMessage, - type LoopRecordedEvent, type Scope, type SessionMeta, } from '@moonshot-ai/agent-core-v2'; @@ -211,7 +208,7 @@ export class SnapshotReader implements ISnapshotReader { if (cached !== undefined) this.transcriptCache.delete(sid); const records = await readWireRecords(wirePath); - const messages = reduceContextRecords(records); + const messages = [...reduceContextTranscript(records).entries]; this.transcriptCache.set(sid, { size: info.size, mtimeMs: info.mtimeMs, messages }); while (this.transcriptCache.size > this.deps.config.cacheLimit) { const oldest = this.transcriptCache.keys().next().value; @@ -283,61 +280,6 @@ interface ContextRecord { readonly [key: string]: unknown; } -/** - * Reduce folded `context.*` wire records into the live `ContextMessage[]` view, - * routing through the exact `contextOps.ts` reducers (`foldAppendMessage` / - * `foldLoopEvent` / `resetFold`) so the result is byte-identical to the live - * `IAgentContextMemoryService.get()` view: - * append_message → push (deferred while a tool exchange is open) · - * append_loop_event → v1 fold (step/content/tool) · splice → array splice · - * clear → drop all · apply_compaction → `[summary, ...state.slice(compactedCount)]` · - * undo → trailing cut. - */ -export function reduceContextRecords(records: Iterable): ContextMessage[] { - let state: readonly ContextMessage[] = []; - for (const record of records) { - switch (record.type) { - case 'context.append_message': { - state = foldAppendMessage(state, record['message'] as ContextMessage); - break; - } - case 'context.append_loop_event': { - state = foldLoopEvent(state, record['event'] as LoopRecordedEvent); - break; - } - case 'context.splice': { - const start = record['start'] as number; - const deleteCount = record['deleteCount'] as number; - const messages = record['messages'] as readonly ContextMessage[]; - if (deleteCount === 0 && messages.length === 0) break; - const next = state.slice(); - next.splice(start, deleteCount, ...messages); - state = resetFold(next); - break; - } - case 'context.clear': { - if (state.length !== 0) state = resetFold([]); - break; - } - case 'context.apply_compaction': { - state = applyContextCompactionRecord(state, record); - break; - } - case 'context.undo': { - const count = record['count'] as number; - if (count <= 0 || state.length === 0) break; - const { cutIndex, removedCount } = computeUndoCut(state, count); - if (cutIndex < 0 || removedCount < count) break; - state = resetFold(state.slice(0, cutIndex)); - break; - } - default: - break; - } - } - return state as ContextMessage[]; -} - /** * Parse a `wire.jsonl` file. A torn final line (crash mid-flush) is dropped; * corruption anywhere else throws so the route surfaces 50001. The leading diff --git a/packages/kap-server/test/messages.test.ts b/packages/kap-server/test/messages.test.ts index 83df8a1b7..8b6870e5b 100644 --- a/packages/kap-server/test/messages.test.ts +++ b/packages/kap-server/test/messages.test.ts @@ -302,10 +302,11 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => { ]); await agent.accessor.get(IAgentWireRecordService).flush(); - // Live (post-compaction) context exposes only the folded summary; capture - // its id so we can assert it survives the restart unchanged. + // The live read already serves the full transcript (pre-compaction prefix + // + summary), matching v1's `/messages`. Capture the summary id so we can + // assert it survives the restart unchanged. const livePage = await getJson(`/api/v1/sessions/${id}/messages?page_size=100`); - expect(livePage.body.data.items).toHaveLength(1); + expect(livePage.body.data.items).toHaveLength(4); const liveSummaryId = livePage.body.data.items[0]!.id; // Restart the server on the same homeDir → the session is cold for the next diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 461d243ba..673909c91 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { IEventService } from '@moonshot-ai/agent-core-v2'; +import { IEventService, ISessionLifecycleService } from '@moonshot-ai/agent-core-v2'; import { sessionWarningsResponseSchema } from '@moonshot-ai/protocol'; import { type RunningServer, startServer } from '../src/start'; @@ -323,6 +323,24 @@ describe('server-v2 /api/v1/sessions', () => { expect(got.body.data.archived).toBe(true); }); + it('cold-loads a persisted session on :undo instead of 40401', async () => { + const cwd = home as string; + const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const id = created.body.data.id; + + // Drop the live handle so the session is persisted-but-cold (index + disk + // only) — the state right after opening a session in the web UI before any + // prompt has been sent. Before the fix, `:undo` resolved the main agent via + // `lifecycle.get` (memory only) and reported 40401 "session does not exist". + await (server as RunningServer).core.accessor.get(ISessionLifecycleService).close(id); + + const res = await postJson<{ messages: unknown }>(`/api/v1/sessions/${id}:undo`, { count: 1 }); + // Cold-loaded successfully: the empty history yields "nothing to undo" + // (40911), not the pre-fix "session does not exist" (40401). + expect(res.body.code).toBe(40911); + expect(res.body.msg).toMatch(/nothing to undo/i); + }); + it('rejects an unsupported action suffix (40001)', async () => { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); diff --git a/packages/kap-server/test/skills.test.ts b/packages/kap-server/test/skills.test.ts index a8cda5459..7d6984678 100644 --- a/packages/kap-server/test/skills.test.ts +++ b/packages/kap-server/test/skills.test.ts @@ -146,16 +146,18 @@ describe('server-v2 /api/v1 skills', () => { expect(body.msg).toMatch(/does not exist/); }); - it('returns 40401 with an activation hint for a persisted but not activated session', async () => { + it('cold-loads a persisted but not live (archived) session and lists skills', async () => { const id = await createSession(); // Archiving removes the session from the live map but keeps it in the index. const archived = await postJson<{ archived: boolean }>(`/api/v1/sessions/${id}:archive`); expect(archived.body.code).toBe(0); - const { body } = await getJson(`/api/v1/sessions/${id}/skills`); - expect(body.code).toBe(40401); - expect(body.msg).toMatch(/not activated/); - expect(body.msg).toMatch(/activate it first/); + // Cold-loaded on demand (matches v1's `resumeSession` in SkillService): + // listing skills succeeds instead of reporting "not activated". + const { body } = await getJson<{ skills: SkillWire[] }>(`/api/v1/sessions/${id}/skills`); + expect(body.code).toBe(0); + const skills = listSkillsResponseSchema.parse(body.data).skills; + expect(skills.some((s) => s.name === 'update-config')).toBe(true); }); it('lists builtin skills projected to the wire shape', async () => { diff --git a/packages/kap-server/test/snapshotReader.unit.test.ts b/packages/kap-server/test/snapshotReader.unit.test.ts index b14435cf9..c357bb3f7 100644 --- a/packages/kap-server/test/snapshotReader.unit.test.ts +++ b/packages/kap-server/test/snapshotReader.unit.test.ts @@ -24,7 +24,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import { loadSnapshotConfig, - reduceContextRecords, readWireRecords, SnapshotNotFoundError, SnapshotReader, @@ -242,7 +241,7 @@ describe('SnapshotReader.read', () => { expect((tool.content[0] as { tool_call_id: string }).tool_call_id).toBe('call_1'); }); - it('reduces context.apply_compaction to [summary, ...tail]', async () => { + it('keeps the full history across context.apply_compaction and appends a summary marker', async () => { const f = await makeFixtureAsync(); await seedSession(f, 'sess_compact'); await writeWire(f.sessionDir('sess_compact'), [ @@ -257,10 +256,11 @@ describe('SnapshotReader.read', () => { ]); const snap = await f.reader.read('sess_compact'); const texts = snap.messages.items.map((m) => (m.content[0] as { text: string }).text); - expect(texts).toEqual(['summary', 'after']); + expect(texts).toEqual(['old-1', 'old-2', 'summary', 'after']); + expect(snap.messages.items[2]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } }); }); - it('reduces v1-shaped string summary compaction records', async () => { + it('keeps the full history across v1-shaped string summary compaction records', async () => { const f = await makeFixtureAsync(); await seedSession(f, 'sess_compact_v1'); await writeWire(f.sessionDir('sess_compact_v1'), [ @@ -278,11 +278,11 @@ describe('SnapshotReader.read', () => { const snap = await f.reader.read('sess_compact_v1'); const messages = snap.messages.items; const texts = messages.map((m) => (m.content[0] as { text: string }).text); - expect(texts).toEqual(['summary', 'after']); - expect(messages[0]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } }); + expect(texts).toEqual(['old-1', 'old-2', 'summary', 'after']); + expect(messages[2]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } }); }); - it('reduces new compaction records to kept user messages followed by contextSummary', async () => { + it('keeps compacted-away assistant messages and uses the raw summary as the marker', async () => { const f = await makeFixtureAsync(); await seedSession(f, 'sess_compact_kept_users'); await writeWire(f.sessionDir('sess_compact_kept_users'), [ @@ -309,12 +309,49 @@ describe('SnapshotReader.read', () => { const snap = await f.reader.read('sess_compact_kept_users'); const messages = snap.messages.items; const texts = messages.map((m) => (m.content[0] as { text: string }).text); - expect(messages.map((m) => m.role)).toEqual(['user', 'user', 'user']); - expect(texts).toEqual(['old user', 'recent user', 'model-facing summary']); - expect(messages[2]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } }); + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user', 'user']); + expect(texts).toEqual(['old user', 'old assistant', 'recent user', 'raw summary']); + expect(messages[3]?.metadata).toEqual({ origin: { kind: 'compaction_summary' } }); }); - it('honors context.clear and context.undo', async () => { + it('preserves the pre-compaction assistant reply after a later undo', async () => { + // Regression: send A, /compact, send B, undo. The snapshot must still show + // A's assistant reply (compaction folds only the live context; the + // transcript keeps the full history). + const f = await makeFixtureAsync(); + await seedSession(f, 'sess_compact_undo'); + const assistant = (text: string): ContextMessage => ({ + role: 'assistant', + content: [{ type: 'text', text }], + toolCalls: [], + }); + await writeWire(f.sessionDir('sess_compact_undo'), [ + { type: 'context.append_message', message: userMessage('message A') }, + { type: 'context.append_message', message: assistant('reply A') }, + { + type: 'context.apply_compaction', + summary: 'summary text', + contextSummary: 'model-facing summary', + compactedCount: 2, + tokensBefore: 100, + tokensAfter: 20, + keptUserMessageCount: 1, + }, + { type: 'context.append_message', message: userMessage('message B') }, + { type: 'context.append_message', message: assistant('reply B') }, + { type: 'context.undo', count: 1 }, + ]); + const snap = await f.reader.read('sess_compact_undo'); + const messages = snap.messages.items; + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'user']); + expect(messages.map((m) => (m.content[0] as { text: string }).text)).toEqual([ + 'message A', + 'reply A', + 'summary text', + ]); + }); + + it('keeps pre-clear messages in the transcript and lets undo remove the tail', async () => { const f = await makeFixtureAsync(); await seedSession(f, 'sess_ops'); await writeWire(f.sessionDir('sess_ops'), [ @@ -322,8 +359,10 @@ describe('SnapshotReader.read', () => { { type: 'context.append_message', message: userMessage('b') }, { type: 'context.clear' }, { type: 'context.append_message', message: userMessage('c') }, + { type: 'context.undo', count: 1 }, ]); - expect((await f.reader.read('sess_ops')).messages.items.map((m) => (m.content[0] as { text: string }).text)).toEqual(['c']); + // /clear keeps prior messages for display; undo removes the post-clear tail (c). + expect((await f.reader.read('sess_ops')).messages.items.map((m) => (m.content[0] as { text: string }).text)).toEqual(['a', 'b']); }); it('caps the page at 100 and flags has_more', async () => { @@ -395,95 +434,6 @@ describe('SnapshotReader.read', () => { }); }); -// ─── pure helpers ───────────────────────────────────────────────────────── - -describe('reduceContextRecords', () => { - it('ignores unknown records and the metadata envelope', () => { - const out = reduceContextRecords([ - { type: 'metadata', protocol_version: '1.4' }, - { type: 'turn.started', turnId: 1 }, - { type: 'context.append_message', message: userMessage('x') }, - ]); - expect(out).toHaveLength(1); - }); - - it('applies context.splice', () => { - const out = reduceContextRecords([ - { type: 'context.append_message', message: userMessage('a') }, - { type: 'context.splice', start: 1, deleteCount: 0, messages: [userMessage('b')] }, - ]); - expect(out).toHaveLength(2); - expect((out[1]!.content[0] as { text: string }).text).toBe('b'); - }); - - it('folds context.append_loop_event (step/content/tool) into messages', () => { - const out = reduceContextRecords([ - { type: 'context.append_message', message: userMessage('q') }, - { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1' } }, - { - type: 'context.append_loop_event', - event: { type: 'content.part', stepUuid: 's1', part: { type: 'text', text: 'hello' } }, - }, - { - type: 'context.append_loop_event', - event: { - type: 'tool.call', - stepUuid: 's1', - toolCallId: 'call_1', - name: 'Bash', - args: { command: 'echo hi' }, - }, - }, - { - type: 'context.append_loop_event', - event: { type: 'tool.result', toolCallId: 'call_1', result: { output: 'hi' } }, - }, - { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 's1' } }, - ]); - expect(out.map((m) => m.role)).toEqual(['user', 'assistant', 'tool']); - expect((out[1]!.content[0] as { text: string }).text).toBe('hello'); - expect(out[1]!.toolCalls).toHaveLength(1); - expect(out[1]!.toolCalls[0]!.id).toBe('call_1'); - expect(out[1]!.partial).toBeUndefined(); - expect(out[2]!.toolCallId).toBe('call_1'); - }); - - it('defers context.append_message until the open tool exchange closes', () => { - const out = reduceContextRecords([ - { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1' } }, - { - type: 'context.append_loop_event', - event: { type: 'tool.call', stepUuid: 's1', toolCallId: 'call_1', name: 'Bash', args: {} }, - }, - // Arrives while the tool exchange is still open — must not land between - // the assistant and its tool result. - { type: 'context.append_message', message: userMessage('injected') }, - { - type: 'context.append_loop_event', - event: { type: 'tool.result', toolCallId: 'call_1', result: { output: 'ok' } }, - }, - { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 's1' } }, - ]); - expect(out.map((m) => m.role)).toEqual(['assistant', 'tool', 'user']); - expect((out[2]!.content[0] as { text: string }).text).toBe('injected'); - }); - - it('synthesizes an interrupted result for a tool call left open at a step boundary', () => { - const out = reduceContextRecords([ - { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1' } }, - { - type: 'context.append_loop_event', - event: { type: 'tool.call', stepUuid: 's1', toolCallId: 'call_1', name: 'Bash', args: {} }, - }, - // No tool.result before the next step begins — the dangling call is closed. - { type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's2' } }, - { type: 'context.append_loop_event', event: { type: 'step.end', uuid: 's2' } }, - ]); - expect(out.map((m) => m.role)).toEqual(['assistant', 'tool', 'assistant']); - expect(out[1]!.isError).toBe(true); - }); -}); - describe('readWireRecords', () => { it('drops a torn final line but throws on mid-file corruption', async () => { const dir = await mkdtemp(join(tmpdir(), 'kimi-wire-'));