kimi-code/packages/agent-core/test/services/message-transcript.test.ts
Kai 743f66e547
refactor: move tool-result metadata into a structured note side channel (#1437)
* fix: stop rendering <system> notes from tool results in the terminal and web UIs

Tool results carry <system> blocks as side-channel notes for the model (ReadMediaFile summaries, Read status, MCP image captions, error/empty sentinels). Keep them in history for the model, but strip them at every core-to-UI boundary so they no longer render as plain text. vis is intentionally left untouched to preserve the model's-eye view for debugging.

* fix: keep error/empty status text visible when stripping tool-result <system> tags

Unwrap the tool error/empty sentinels (<system>ERROR: ...</system>, <system>Tool output is empty.</system>) instead of deleting them: keep the human-readable text and drop only the tags. Otherwise a failed or empty tool result rendered as a blank output, indistinguishable from a rendering bug. The model still reads the wrapped form in history.

* refactor: move tool-result metadata into a structured note side channel

Tool-produced model-facing metadata (ReadMediaFile summaries, Read status
lines, MCP image-compression captions) was baked into tool output as
<system> text, so every UI had to strip it back out and three copies of
the model-view normalization had silently drifted apart.

- ExecutableToolResult gains `note`: content rendered to the model but
  never to UIs; records and history now store the raw output plus the
  structured isError/note fields
- the model view is rendered exactly once at the LLM projection boundary
  by renderToolResultForModel; the transcript and vis hand-copies are
  deleted (vis now calls the same function for its model view, fixing
  their drifted empty-output checks)
- ReadMediaFile / Read / MCP captions write `note`; tool outputs stay
  pure data, and text-only results keep a single text part (note joined
  with a newline) so provider tool content stays a plain string
- all UI-side <system> stripping is removed; failed tools show their own
  error text with the structured isError flag
- wire protocol 1.4 -> 1.5 migrates existing records' tool-produced
  <system> blocks into `note` on resume

* fix: provider-neutral wording, no wire migration, direct optional fields

- "The attached image was downsampled" replaces directional wording that
  depended on provider serialization order (inline media vs flatten-and-
  re-attach)
- drop the 1.4 -> 1.5 wire migration: legacy records replay verbatim, so
  the model view of old sessions stays byte-identical to what the model
  originally saw and UIs show the legacy <system> text as-is; this also
  removes the risk of the migration misclassifying user data that quotes
  tool metadata, and the additive note field needs no version bump
- pass optional result fields as undefined instead of conditional spreads
  (repo convention)

* fix: enforce the note contract at the trust boundary; narrow the TUI system-tag guard

- normalizeToolResult now keeps a note only when it is a non-empty string:
  tools and finalize hooks are arbitrary JS, and a malformed note (null,
  number, object) would previously persist into the record and crash every
  subsequent LLM projection of the session. Everything downstream now
  trusts note to be string | undefined.
- the TUI tool body suppression matches the full <system-reminder> tag
  instead of any <system prefix: reminder piggy-backing stays hidden,
  while real output that merely starts with a literal <system> tag (file
  contents, MCP text) stays visible, covered through the real
  ToolCallComponent path.

* fix: return MCP compression captions as data instead of extracting them from text

compressImageContentParts now returns { parts, captions } — captions come
back from the compressor as structured data and are never inserted into
the parts, so the MCP pipeline no longer pattern-matches text to move
them into the note side channel. Tool output that merely quotes a
caption (a doc, a log, a test fixture) stays verbatim in the output.
Also corrects the stale claim that prompt ingestion uses this helper
(it compresses per image while constructing the part).

* docs: correct the image-compression re-export comment; export CompressedContentParts

The package-root comment still described compressImageContentParts as the
input-stage helper every ingestion site calls; prompt ingestion compresses
per image with compressBase64ForModel / compressImageForModel, and the MCP
pipeline is the walker's only caller. Also export the walker's
CompressedContentParts return type so public-API consumers can name it.

* feat: wrap tool status sentinels in <system> so the model can tell harness verdicts from tool output

The error/empty status text is model-only after the note refactor (UIs
render the raw output and style failures via the structured isError
flag), so the earlier plain-text wording served no remaining audience.
Wrapping the statuses in <system> gives every piece of system-generated
text inside a tool result the same marker:

- failed calls get '<system>ERROR: Tool execution failed.</system>'
  unconditionally — the ERROR:-prefix guard is removed, so the harness
  verdict can no longer be confused with tool output that happens to
  start with error-like text
- empty outputs render as '<system>Tool output is empty.</system>'; the
  plain placeholder the loop layer bakes into records is still
  recognized and upgraded at projection time

* style: collapse an internal helper docstring per the services subtree convention
2026-07-07 11:40:27 +08:00

689 lines
24 KiB
TypeScript

/**
* Wire-transcript reader tests (full-history view for compacted sessions).
*
* Coverage:
* - reduceWireRecords: append + loop events; compaction keeps the prefix and
* inserts the summary at the fold point; undo (skip injections, stop at
* compaction summaries and clear floors); clear resets the folded view but
* keeps the transcript; deferred messages during an open tool exchange;
* tool.result `<system>` status wrapping
* - readWireRecords: tolerates a torn final line, throws on mid-file corruption
* - readWireTranscript: blobref → data URI rehydration from the blobs dir
* - MessageService: compacted session lists its FULL history; unflushed live
* tail is appended; wire-derived record times drive created_at
*/
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
AgentContextData,
AgentRecord,
ContextMessage,
CoreRPC,
SessionSummary,
} from '../../src';
import {
type ICoreProcessService,
MessageService,
readWireRecords,
readWireTranscript,
reduceWireRecords,
} from '../../src/services';
const SESSION_ID = 'sess_01HZWIRE';
const SESSION_CREATED_AT = 1_700_000_000_000;
function userMessage(text: string, origin?: ContextMessage['origin']): ContextMessage {
return {
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
...(origin !== undefined ? { origin } : {}),
} as ContextMessage;
}
function appendMessage(message: ContextMessage, time?: number): AgentRecord {
return { type: 'context.append_message', message, time } as AgentRecord;
}
function loopEvent(event: Record<string, unknown>, time?: number): AgentRecord {
return { type: 'context.append_loop_event', event, time } as unknown as AgentRecord;
}
/** step.begin + content.part + step.end producing one assistant text message. */
function assistantStep(uuid: string, text: string, time?: number): AgentRecord[] {
return [
loopEvent({ type: 'step.begin', uuid, turnId: 't', step: 0 }, time),
loopEvent({ type: 'content.part', uuid: 'p', turnId: 't', step: 0, stepUuid: uuid, part: { type: 'text', text } }),
loopEvent({ type: 'step.end', uuid, turnId: 't', step: 0 }),
];
}
function compaction(
summary: string,
compactedCount: number,
time?: number,
keptUserMessageCount?: number,
): AgentRecord {
return {
type: 'context.apply_compaction',
summary,
compactedCount,
tokensBefore: 1000,
tokensAfter: 100,
time,
...(keptUserMessageCount === undefined ? {} : { keptUserMessageCount }),
} as AgentRecord;
}
function textOf(message: ContextMessage): string {
return message.content
.map((p) => (p.type === 'text' ? p.text : `[${p.type}]`))
.join('');
}
describe('reduceWireRecords', () => {
it('builds the transcript from append_message and loop events', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
...assistantStep('s1', 'a1'),
]);
expect(entries.map((e) => textOf(e.message))).toEqual(['u1', 'a1']);
expect(entries.map((e) => e.message.role)).toEqual(['user', 'assistant']);
expect(foldedLength).toBe(2);
});
it('compaction keeps the prefix and appends the user-role summary', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
...assistantStep('s1', 'a1'),
appendMessage(userMessage('u2')),
...assistantStep('s2', 'a2'),
compaction('SUM', 4),
appendMessage(userMessage('u3')),
]);
expect(entries.map((e) => textOf(e.message))).toEqual([
'u1',
'a1',
'u2',
'a2',
'SUM',
'u3',
]);
expect(entries[4]!.message.origin).toEqual({ kind: 'compaction_summary' });
expect(entries[4]!.message.role).toBe('user');
// live folded view would be [u1, u2, SUM, u3]
expect(foldedLength).toBe(4);
});
it('keeps shell and local-command output in the transcript but not foldedLength', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
appendMessage(userMessage('! pwd', { kind: 'shell_command', phase: 'input' })),
appendMessage(userMessage('local output', { kind: 'injection', variant: 'local-command-stdout' })),
...assistantStep('s1', 'a1'),
{
type: 'context.apply_compaction',
summary: 'SUM',
compactedCount: 4,
tokensBefore: 100,
tokensAfter: 20,
keptUserMessageCount: 1,
} as AgentRecord,
appendMessage(userMessage('u2')),
]);
expect(entries.map((e) => textOf(e.message))).toEqual([
'u1',
'! pwd',
'local output',
'a1',
'SUM',
'u2',
]);
expect(entries.map((e) => e.message.role)).toEqual([
'user',
'user',
'user',
'assistant',
'user',
'user',
]);
// 1 kept real user message + summary + u2 appended after compaction.
expect(foldedLength).toBe(3);
});
it('accounts for the elision marker when the compaction record kept a head segment', () => {
const { foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
appendMessage(userMessage('u2')),
...assistantStep('s1', 'a1'),
{
type: 'context.apply_compaction',
summary: 'SUM',
compactedCount: 3,
tokensBefore: 100_000,
tokensAfter: 20_000,
keptUserMessageCount: 2,
keptHeadUserMessageCount: 1,
} as AgentRecord,
]);
// Live context: head user message + elision marker + tail user message + summary.
expect(foldedLength).toBe(4);
});
it('handles repeated compactions', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
compaction('S1', 1),
appendMessage(userMessage('u2')),
compaction('S2', 3),
]);
expect(entries.map((e) => textOf(e.message))).toEqual(['u1', 'S1', 'u2', 'S2']);
// live folded view would be [u1, u2, S2]
expect(foldedLength).toBe(3);
});
it('uses the recorded kept-user count for foldedLength when present', () => {
// The live context kept only the most recent real user message (e.g. the
// older ones were truncated in a prior compaction, or a clear dropped
// them). The full transcript still holds all three, so re-deriving from
// it would yield 3 and disagree with the live context. The reducer must
// trust the count recorded by ContextMemory.applyCompaction.
const { foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
appendMessage(userMessage('u2')),
appendMessage(userMessage('u3')),
{
type: 'context.apply_compaction',
summary: 'SUM',
compactedCount: 3,
tokensBefore: 100,
tokensAfter: 20,
keptUserMessageCount: 1,
} as AgentRecord,
appendMessage(userMessage('u4')),
]);
// 1 kept user message + summary + u4 appended after compaction.
expect(foldedLength).toBe(3);
});
it('drops a late tool result after compaction closes an open exchange', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }),
loopEvent({
type: 'tool.call',
uuid: 'c1',
turnId: 't',
step: 0,
stepUuid: 's1',
toolCallId: 'call_1',
name: 'Bash',
arguments: '{"command":"ls"}',
}),
compaction('SUM', 3),
loopEvent({
type: 'tool.result',
parentUuid: 'c1',
toolCallId: 'call_1',
result: { output: 'late result' },
}),
appendMessage(userMessage('u2')),
]);
// Compaction closes the open exchange, so the late tool result is an
// orphan and dropped — matching ContextMemory — and the following user
// message is appended normally instead of being stranded in `deferred`.
expect(entries.map((e) => e.message.role)).toEqual(['user', 'assistant', 'user', 'user']);
expect(entries.map((e) => textOf(e.message))).toEqual(['u1', '', 'SUM', 'u2']);
// live folded view would be [u1, SUM, u2]
expect(foldedLength).toBe(3);
});
it('reproduces the legacy [summary, tail] fold length for records without keptUserMessageCount', () => {
// A pre-rework record (no keptUserMessageCount) kept history.slice(compactedCount)
// verbatim, and ContextMemory's legacy restore now reproduces [summary, ...tail].
// The reducer must track that same folded length — 1 + (preCompactionLength -
// compactedCount) — not the re-derived kept-user count, or MessageService's
// length comparison diverges from the live context for old sessions.
const { foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
...assistantStep('s1', 'a1'),
appendMessage(userMessage('u2')),
...assistantStep('s2', 'a2'),
compaction('SUM', 1),
]);
// Pre-compaction live history = [u1, a1, u2, a2] (4); legacy restore keeps
// [SUM, ...slice(1)] = [SUM, a1, u2, a2] = 4. (Re-deriving kept users gives 3.)
expect(foldedLength).toBe(4);
});
it('ignores pre-clear prompts when re-deriving a legacy fold length', () => {
// Legacy record (no keptUserMessageCount) compacting after a /clear with no
// tail re-derives the kept-user count, but only from post-clear messages —
// the live context dropped u1/u2 at the clear. Counting them would overstate
// foldedLength and make MessageService skip the unflushed live tail.
const { foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
appendMessage(userMessage('u2')),
{ type: 'context.clear' } as AgentRecord,
appendMessage(userMessage('u3')),
compaction('SUM', 1),
]);
// Post-clear live history = [u3] (1); restore keeps [u3, SUM] = 2.
// (Re-deriving over the full transcript would wrongly give 4.)
expect(foldedLength).toBe(2);
});
it('undo removes through the last real user prompt and skips injections', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
...assistantStep('s1', 'a1'),
appendMessage(userMessage('u2')),
appendMessage(userMessage('note', { kind: 'injection', variant: 'x' })),
...assistantStep('s2', 'a2'),
{ type: 'context.undo', count: 1 } as AgentRecord,
]);
// a2 removed, injection skipped (kept), u2 removed → stop.
expect(entries.map((e) => textOf(e.message))).toEqual(['u1', 'a1', 'note']);
expect(foldedLength).toBe(3);
});
it('undo stops at a compaction summary boundary', () => {
const { entries } = reduceWireRecords([
appendMessage(userMessage('u1')),
compaction('SUM', 1),
appendMessage(userMessage('u2')),
{ type: 'context.undo', count: 5 } as AgentRecord,
]);
expect(entries.map((e) => textOf(e.message))).toEqual(['u1', 'SUM']);
});
it('clear keeps prior messages in the transcript and floors later undos', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
{ type: 'context.clear' } as AgentRecord,
appendMessage(userMessage('u2')),
{ type: 'context.undo', count: 5 } as AgentRecord,
]);
expect(entries.map((e) => textOf(e.message))).toEqual(['u1']);
expect(foldedLength).toBe(0);
});
it('defers messages appended during an open tool exchange', () => {
const { entries } = reduceWireRecords([
loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }),
loopEvent({
type: 'tool.call',
uuid: 'c1',
turnId: 't',
step: 0,
stepUuid: 's1',
toolCallId: 'call_1',
name: 'Bash',
args: { command: 'ls' },
}),
appendMessage(userMessage('steer', { kind: 'injection', variant: 'steer' })),
loopEvent({
type: 'tool.result',
parentUuid: 's1',
toolCallId: 'call_1',
result: { output: 'ok' },
}),
loopEvent({ type: 'step.end', uuid: 's1', turnId: 't', step: 0 }),
]);
expect(entries.map((e) => e.message.role)).toEqual(['assistant', 'tool', 'user']);
expect(entries[0]!.message.toolCalls).toEqual([
{ type: 'function', id: 'call_1', name: 'Bash', arguments: '{"command":"ls"}' },
]);
expect(entries[1]!.message.toolCallId).toBe('call_1');
expect(textOf(entries[1]!.message)).toBe('ok');
});
it('closes a tool call interrupted mid-history at the next step.begin', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }),
loopEvent({
type: 'tool.call',
uuid: 'c1',
turnId: 't',
step: 0,
stepUuid: 's1',
toolCallId: 'call_interrupted',
name: 'Lookup',
args: { query: 'one' },
}),
// Recorded while the exchange was open, so it was deferred live.
appendMessage(userMessage('keep going')),
...assistantStep('s2', 'a2'),
]);
expect(entries.map((e) => e.message.role)).toEqual([
'user',
'assistant',
'tool',
'user',
'assistant',
]);
// Synthetic result spliced in place (index 2), before the deferred prompt.
expect(entries[2]!.message.toolCallId).toBe('call_interrupted');
expect(entries[2]!.message.isError).toBe(true);
// Raw fact, like ContextMemory history: the ERROR status prefix is a
// model-projection concern, not part of the stored message.
expect(textOf(entries[2]!.message)).toBe(
'Tool execution was interrupted before its result was recorded. ' +
'Do not assume the tool completed successfully.',
);
expect(textOf(entries[3]!.message)).toBe('keep going');
expect(textOf(entries[4]!.message)).toBe('a2');
expect(foldedLength).toBe(5);
});
it('drops a stale tail interrupted result already closed in place', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }),
loopEvent({
type: 'tool.call',
uuid: 'c1',
turnId: 't',
step: 0,
stepUuid: 's1',
toolCallId: 'call_interrupted',
name: 'Lookup',
args: { query: 'one' },
}),
appendMessage(userMessage('keep going')),
...assistantStep('s2', 'a2'),
// The stale synthetic result an older tail-only resume appended.
loopEvent({
type: 'tool.result',
parentUuid: 'call_interrupted',
toolCallId: 'call_interrupted',
result: {
output:
'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.',
isError: true,
},
}),
]);
expect(entries.map((e) => e.message.role)).toEqual([
'user',
'assistant',
'tool',
'user',
'assistant',
]);
expect(entries[2]!.message.toolCallId).toBe('call_interrupted');
expect(foldedLength).toBe(5);
});
it('closes every open call of a multi-call interrupted step, keeping foldedLength aligned', () => {
const { entries, foldedLength } = reduceWireRecords([
loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }),
...['call_a', 'call_b'].map((toolCallId) =>
loopEvent({
type: 'tool.call',
uuid: toolCallId,
turnId: 't',
step: 0,
stepUuid: 's1',
toolCallId,
name: 'Run',
args: {},
}),
),
...assistantStep('s2', 'a2'),
]);
expect(entries.map((e) => e.message.role)).toEqual([
'assistant',
'tool',
'tool',
'assistant',
]);
expect(entries[1]!.message.toolCallId).toBe('call_a');
expect(entries[2]!.message.toolCallId).toBe('call_b');
expect(foldedLength).toBe(4);
});
it('drops an orphan tool result whose call was never recorded', () => {
const { entries, foldedLength } = reduceWireRecords([
appendMessage(userMessage('u1')),
...assistantStep('s1', 'a1'),
loopEvent({
type: 'tool.result',
parentUuid: 'ghost',
toolCallId: 'call_ghost',
result: { output: 'orphaned' },
}),
]);
expect(entries.map((e) => e.message.role)).toEqual(['user', 'assistant']);
expect(foldedLength).toBe(2);
});
it('keeps raw tool output with the structured isError/note fields like agent-core history', () => {
const { entries } = reduceWireRecords([
loopEvent({ type: 'step.begin', uuid: 's1', turnId: 't', step: 0 }),
...['call_err', 'call_empty'].map((toolCallId) =>
loopEvent({
type: 'tool.call',
uuid: toolCallId,
turnId: 't',
step: 0,
stepUuid: 's1',
toolCallId,
name: 'Run',
args: {},
}),
),
loopEvent({
type: 'tool.result',
parentUuid: 's1',
toolCallId: 'call_err',
result: { output: 'boom', isError: true },
}),
loopEvent({
type: 'tool.result',
parentUuid: 's1',
toolCallId: 'call_empty',
result: { output: '', note: '<system>meta</system>' },
}),
]);
expect(textOf(entries[1]!.message)).toBe('boom');
expect(entries[1]!.message.isError).toBe(true);
expect(textOf(entries[2]!.message)).toBe('');
expect(entries[2]!.message.note).toBe('<system>meta</system>');
});
});
describe('readWireRecords / readWireTranscript', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'kimi-wire-test-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('drops a torn final line but throws on mid-file corruption', async () => {
const good = JSON.stringify(appendMessage(userMessage('u1')));
const torn = path.join(dir, 'torn.jsonl');
await writeFile(torn, `${good}\n{"type":"context.appe`, 'utf8');
const records = await readWireRecords(torn);
expect(records).toHaveLength(1);
const corrupt = path.join(dir, 'corrupt.jsonl');
await writeFile(corrupt, `not-json\n${good}\n`, 'utf8');
await expect(readWireRecords(corrupt)).rejects.toThrow(/corrupted line 1/);
});
it('rehydrates blobref media urls from the blobs dir', async () => {
const hash = 'deadbeefdeadbeefdeadbeefdeadbeef';
const agentDir = path.join(dir, 'agents', 'main');
await mkdir(path.join(agentDir, 'blobs'), { recursive: true });
await writeFile(path.join(agentDir, 'blobs', hash), Buffer.from('PNG!'));
const message: ContextMessage = {
role: 'user',
content: [
{ type: 'image_url', imageUrl: { url: `blobref:image/png;${hash}` } },
{ type: 'image_url', imageUrl: { url: 'blobref:image/png;0123456789abcdef0123' } },
],
toolCalls: [],
} as ContextMessage;
await writeFile(
path.join(agentDir, 'wire.jsonl'),
`${JSON.stringify(appendMessage(message))}\n`,
'utf8',
);
const transcript = await readWireTranscript(dir, 'main');
const parts = transcript.entries[0]!.message.content as {
imageUrl: { url: string };
}[];
expect(parts[0]!.imageUrl.url).toBe(
`data:image/png;base64,${Buffer.from('PNG!').toString('base64')}`,
);
expect(parts[1]!.imageUrl.url).toBe('[media missing]');
});
});
describe('MessageService over a compacted wire log', () => {
let dir: string;
let liveHistory: ContextMessage[];
let bridge: ICoreProcessService;
let impl: MessageService;
function summary(): SessionSummary {
return {
id: SESSION_ID,
workDir: '/tmp/ws',
sessionDir: dir,
createdAt: SESSION_CREATED_AT,
updatedAt: SESSION_CREATED_AT,
};
}
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'kimi-msg-test-'));
const records: AgentRecord[] = [
appendMessage(userMessage('u1'), SESSION_CREATED_AT + 1_000),
...assistantStep('s1', 'a1', SESSION_CREATED_AT + 2_000),
appendMessage(userMessage('u2'), SESSION_CREATED_AT + 3_000),
...assistantStep('s2', 'a2', SESSION_CREATED_AT + 4_000),
// New-format record: the summary covered all 4 messages and 2 user
// prompts were kept verbatim, so the live fold is [u1, u2, SUM] below.
compaction('SUM', 4, SESSION_CREATED_AT + 5_000, 2),
];
await mkdir(path.join(dir, 'agents', 'main'), { recursive: true });
await writeFile(
path.join(dir, 'agents', 'main', 'wire.jsonl'),
records.map((r) => JSON.stringify(r)).join('\n') + '\n',
'utf8',
);
// What getContext would return after the fold: kept user messages + summary.
liveHistory = [
userMessage('u1'),
userMessage('u2'),
{
role: 'user',
content: [{ type: 'text', text: 'SUM' }],
toolCalls: [],
origin: { kind: 'compaction_summary' },
} as ContextMessage,
];
const rpc: Partial<CoreRPC> = {
listSessions: vi.fn().mockImplementation(async () => [summary()]),
resumeSession: vi.fn().mockResolvedValue(undefined as unknown as never),
getContext: vi.fn().mockImplementation(async (): Promise<AgentContextData> => {
return { history: liveHistory, tokenCount: 0 };
}),
};
bridge = {
rpc: rpc as CoreRPC,
ready: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
_serviceBrand: undefined,
};
impl = new MessageService(bridge);
});
afterEach(async () => {
impl.dispose();
await rm(dir, { recursive: true, force: true });
});
it('lists the FULL history (compacted prefix + summary + tail)', async () => {
const page = await impl.list(SESSION_ID, { page_size: 100 });
const asc = [...page.items].reverse();
expect(
asc.map((m) => (m.content[0] as { text?: string }).text ?? '[non-text]'),
).toEqual(['u1', 'a1', 'u2', 'a2', 'SUM']);
expect(asc[4]!.metadata).toEqual({ origin: { kind: 'compaction_summary' } });
});
it('uses wire record times for created_at, strictly increasing', async () => {
const page = await impl.list(SESSION_ID, { page_size: 100 });
const asc = [...page.items].reverse();
expect(asc[0]!.created_at).toBe(
new Date(SESSION_CREATED_AT + 1_000).toISOString(),
);
const times = asc.map((m) => new Date(m.created_at).getTime());
for (let i = 1; i < times.length; i++) {
expect(times[i]!).toBeGreaterThan(times[i - 1]!);
}
});
it('appends the live tail when memory is ahead of the wire file', async () => {
liveHistory = [...liveHistory, userMessage('u3-live')];
const page = await impl.list(SESSION_ID, { page_size: 100 });
const asc = [...page.items].reverse();
expect(
asc.map((m) => (m.content[0] as { text?: string }).text ?? '[non-text]'),
).toEqual(['u1', 'a1', 'u2', 'a2', 'SUM', 'u3-live']);
});
it('get() resolves ids against the same full transcript', async () => {
const page = await impl.list(SESSION_ID, { page_size: 100 });
const asc = [...page.items].reverse();
const fetched = await impl.get(SESSION_ID, asc[0]!.id);
expect((fetched.content[0] as { text: string }).text).toBe('u1');
expect(fetched.created_at).toBe(asc[0]!.created_at);
});
it('falls back to the live context view when the wire file is unreadable', async () => {
await rm(path.join(dir, 'agents', 'main', 'wire.jsonl'));
const page = await impl.list(SESSION_ID, { page_size: 100 });
const asc = [...page.items].reverse();
expect(asc.map((m) => (m.content[0] as { text?: string }).text)).toEqual([
'u1',
'u2',
'SUM',
]);
});
it('re-reads the wire file after it changes (cache invalidation)', async () => {
await impl.list(SESSION_ID, { page_size: 100 });
const wirePath = path.join(dir, 'agents', 'main', 'wire.jsonl');
const extra = JSON.stringify(
appendMessage(userMessage('u3'), SESSION_CREATED_AT + 6_000),
);
const { readFile } = await import('node:fs/promises');
const prev = await readFile(wirePath, 'utf8');
await writeFile(wirePath, prev + extra + '\n', 'utf8');
liveHistory = [...liveHistory, userMessage('u3')];
const page = await impl.list(SESSION_ID, { page_size: 100 });
const asc = [...page.items].reverse();
expect(
asc.map((m) => (m.content[0] as { text?: string }).text ?? '[non-text]'),
).toEqual(['u1', 'a1', 'u2', 'a2', 'SUM', 'u3']);
});
});