mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-22 15:44:46 +00:00
* 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
689 lines
24 KiB
TypeScript
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']);
|
|
});
|
|
});
|