mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-17 12:45:42 +00:00
* feat(agent-core): rework compaction to keep only user prompts and summary
* refactor(agent-core): rewrite compaction summary as first-person handoff
Rework the full-compaction summary to read as the agent's own continuing
notes instead of a third-party report:
- compaction-instruction.md: free-form first-person continuation that
preserves exact commands, paths and outcomes, states the precise next
action, and flags claimed-but-unverified work rather than trusting it.
- compaction-summary-prefix.md: skeptical "your own working notes"
framing; drop the collaborative third-party prefix.
- system.md: add compaction-awareness guidance so the model continues
naturally from a summary and re-checks any reported "done".
- Rename the compaction helpers module to handoff.ts.
Update tests and regenerate snapshots for the new prompt text, and fill
in contextSummary in the restored-compaction replay expectations.
* fix(agent-core): count image/audio/video parts in token estimation
estimateTokensForContentPart returned 0 for image_url/audio_url/video_url,
so auto-compaction triggers, the overflow-shrink budget, the kept-user
budget, and the reported context size all went blind to media — a
media-heavy session could overflow the model window while the estimate
reported a near-empty context. Media parts now carry a fixed estimate
(MEDIA_TOKEN_ESTIMATE), and the content-part switch is exhaustive so a new
ContentPart kind must declare its estimate rather than silently count as
zero.
* feat(agent-core): re-surface active background tasks after compaction
Folding the live context to [recent user prompts, summary] drops the
messages that started background tasks and their status updates, so the
model could forget a task is still running and spawn a duplicate.
injectAfterCompaction now appends a system-reminder listing active
background tasks (with guidance to use TaskOutput/TaskList/TaskStop
instead of re-spawning). It runs only post-compaction and carries an
injection origin, so the next compaction drops and rebuilds it rather
than stacking copies; the all-user-role post-compaction shape is
preserved (no tool-pairing reintroduced).
* test(agent-core): add compaction scenario guards and risk probes
Adds compaction-scenarios.test.ts driving the real Agent/ContextMemory/
FullCompaction machinery:
- A guard test locking in that repeated compaction folds the prior summary
into the new one instead of stacking two summaries.
- Seven `it.fails` probes that executably reproduce known, currently-accepted
edge-case defects so the suite stays green while documenting each one
precisely; any of them will flip red (forcing removal of `.fails`) the day
the behavior is fixed. They cover: assistant/tool appended during an
in-flight summarizer call being dropped; unbounded shrink on empty
summaries; the fixed 20k kept-user budget overflowing a small model window;
a tool result orphaned when compaction starts mid-exchange; legacy
compaction records dropping their verbatim tail on replay; micro-compaction
clearing recent tool results in an overflow-shrunk suffix; and media being
discarded when the oldest kept user message is truncated.
* fix(agent-core): repair tool_use/tool_result adjacency in projected context
A tool call and its result can end up non-adjacent in history — a
background-task notification or flushed steer lands between them, or an
interrupted/nested step delays the result — which strict providers reject
with HTTP 400. The projector now moves each tool_use's result up to
immediately follow it (projection-time only; the stored history is
untouched), and full compaction projects its summarizer input with a
synthetic result for any still-open call so the summary request stays
well-formed. Micro-compaction only surfaced this latent ordering by busting
the prompt cache, so it now defaults off.
Includes projector adjacency regression tests, a context-level integration
test, and a compaction synthesize-missing guard; the prior "keeps an
unresolved tool exchange out of the compaction prompt" test is updated to
the now-well-formed (synthetic-result) behavior.
* fix(agent-core): preserve the verbatim tail when restoring legacy compactions
A pre-rework `context.apply_compaction` record used
`[summary, ...history.slice(compactedCount)]` semantics and kept a verbatim
recent tail, but it has no `keptUserMessageCount`. The reworked applyCompaction
re-folded such records into the all-user shape, dropping the recent
assistant/tool tail — so resuming a session compacted by an older version
silently lost its most recent context.
On restore of such a record (gated on records.restoring, no keptUserMessageCount,
and compactedCount < history length) reproduce the old shape instead. The
forward/live path is unchanged; the projector's tool-adjacency repair keeps the
restored tail well-formed, and compaction only runs at clean step boundaries so
the tail has no open exchange. The legacy-tail probe now passes as a regression
guard via the real restore path.
* fix(agent-core): align legacy compaction foldedLength with live restore
The transcript reducer re-derived foldedLength for pre-rework
context.apply_compaction records (no keptUserMessageCount) using the new
kept-user+summary rule, but ContextMemory's restore now reproduces the legacy
[summary, ...history.slice(compactedCount)] shape for those records. The two
diverged for legacy sessions, so MessageService's foldedLength-vs-live-history
comparison could mis-handle GET /messages (miss or misorder recent output).
The reducer now mirrors the live legacy fold: when compactedCount is below the
pre-compaction length it computes 1 + (length - compactedCount); otherwise it
falls back to the kept-user derivation. The MessageService transcript test's
fixture is corrected to a new-format record, matching its all-user live mock.
* fix(kosong): merge a follow-up user turn into the preceding tool_results
The Anthropic message merge keyed on isToolResultOnly(last) ===
isToolResultOnly(converted), which left a tool_result-only user turn
followed by a plain-text user turn unmerged. After tool-exchange repair
this shape (assistant tool_use -> tool_result -> injected notification)
produces two adjacent user messages, which strict Anthropic-compatible
backends reject with HTTP 400.
Switch to the asymmetric predicate isToolResultOnly(last) ||
!isToolResultOnly(converted): a tool-result-only running message absorbs
whatever user turn follows (parallel tool_results or a trailing text),
yielding a valid [tool_result, ..., text] message; a plain-text running
message still only absorbs plain text. [tool_result, text] is valid for
both native Anthropic (which concatenates anyway) and strict backends.
* test(agent-core): pin micro-compaction flag in the shrunk-suffix probe
The 'does not clear recent tool results when projecting a shrunk suffix'
probe is an it.fails that only documents a real defect while
micro-compaction is active. It inherited the ambient
KIMI_CODE_EXPERIMENTAL master switch, so its pass/fail flipped with the
runner: green locally (master switch on) but a hard failure in CI, where
the flag defaults off and MicroCompaction.compact() is a no-op that
leaves the tool result intact.
Enable KIMI_CODE_EXPERIMENTAL_MICRO_COMPACTION explicitly for this probe
so it deterministically exercises the micro-compaction path regardless of
the environment.
* fix(agent-core): harden full compaction against in-flight races, unbounded shrink, and media loss
Three compaction-path fixes surfaced by review, each flipping its
documenting it.fails probe to a passing it:
- Append race (CMP-02): after the summarizer returns, the post-summary
history check only compared the compacted prefix. A live step appending
to the tail while a manual/SDK compaction was in flight slipped through —
an appended assistant/tool turn is neither summarized (the summary covers
only the snapshot) nor kept (the rebuild keeps user input), so it
vanished. Now cancel when the appended tail contains a non-user message;
an appended user message is still kept (rebuild picks it up), preserving
the existing 'keeps messages appended while compacting an unchanged
prefix' behavior.
- Unbounded empty/truncated shrink: an empty or truncated summary dropped
the oldest message and reset retryCount, so a model that kept returning
empty could issue ~one request per history entry. Bound the shrink
attempts by MAX_COMPACTION_RETRY_ATTEMPTS, mirroring the overflow-shrink
counter.
- Media dropped on truncation (CMP-07): truncating the oldest kept user
message replaced its whole content with one text block, discarding any
image/audio/video. Keep the non-text parts and spend the remaining budget
(maxTokens minus their cost) on truncated text.
* fix(vis): mirror legacy compaction tail in the model-mode projector
For a pre-rework context.apply_compaction record (no keptUserMessageCount),
agent-core's ContextMemory restore and the transcript reducer keep the old
[summary, ...history.slice(compactedCount)] tail — a verbatim recent tail
including assistant/tool. The vis model-mode projector always applied the
new kept-user selection, so opening an older compacted session in model
mode hid the assistant/tool tail the resumed agent still holds (and
surfaced a pre-compaction user message the agent dropped).
Branch on a missing keptUserMessageCount with compactedCount < history
length and reproduce the legacy shape, matching the agent-core restore.
* fix(agent-core): cancel compaction on any droppable user-role tail
The in-flight append guard cancelled only when the tail grew with a
non-user role. A user-role message that compaction would still drop — a
background-task notification, hook/cron reminder, or shell-command output —
slipped through: appended after the summary snapshot (so absent from the
summary) and dropped by the all-user rebuild (which keeps only real user
input), vanishing silently.
Key the guard on the same predicate applyCompaction uses (!isRealUserInput)
so it cancels whenever the appended tail holds anything compaction would
drop. A real user message is still kept, so a live user turn racing a
manual/SDK compaction continues to complete.
* fix(agent-core): exclude pre-clear prompts from legacy folded length
The transcript reducer's legacy fallback (records predating
keptUserMessageCount, compacted with no verbatim tail) re-derived the
kept-user count from the whole transcript, including messages before the
last context.clear. Live ContextMemory rebuilds _history from post-clear
messages only, so counting pre-clear prompts overstated foldedLength;
MessageService then saw context.history.length <= foldedLength and skipped
appending unflushed live tail messages, dropping recent output from the
messages endpoint for old sessions compacted after a clear.
Derive only from entries at or after clearFloor to match the live context.
* fix(agent-core): drop media when truncating the oldest kept prompt
Revert the media-preserving truncation: keeping non-text parts on the
truncated boundary message overshot the kept-user budget when the media
alone exceeded it, and reordered interleaved text/media parts. Both codex
(no media-aware truncation) and Claude Code (strips media at compaction)
decline to preserve media on a truncated message, since media cannot be
partially truncated and keeping it whole breaks the budget.
truncateUserMessage now keeps only the truncated text. Recent messages
that fit the budget are still kept verbatim with their media; only the
oldest, partially-overflowing boundary message loses its attachments.
* fix(agent-core): make manual compaction and turns mutually exclusive
A manual/SDK compaction could start while a turn was streaming, or a new
turn could launch while a compaction was in flight. Either way the turn
mutates the shared context (streaming content into an existing assistant
message, or appending new messages) during the summarizer await, and that
output is neither summarized nor preserved by the all-user rebuild —
silent loss that object-identity checks can't detect (the streamed message
is mutated in place).
Guard both directions so the agent does one of {turn, compaction} at a
time: begin() refuses a manual compaction while a turn is active, and
launch() refuses a new turn while a compaction is in progress. Auto
compaction is exempt — it runs from within the turn at a step boundary,
which blocks the turn for its duration.
* chore(changeset): consolidate compaction changesets into one
* chore(agent-core): drop external-product references from compaction comments
* test(agent-core): add Anthropic wire-compliance smoke tests for compaction
Drive real compaction output and the compaction summarizer projection
through the real Anthropic provider conversion and assert the wire request
is well-formed: strict user/assistant alternation and every tool_use
answered by an adjacent tool_result. Locks in the cross-layer guarantee
(projector merge + Anthropic consecutive-user merge + adjacency repair +
synthesizeMissing) that compacted sessions stay valid for strict
Anthropic-compatible backends.
* fix(agent-core): defer and replay inputs during manual compaction instead of rejecting
Manual/SDK compaction runs outside a turn, so the earlier guard rejected
prompts/steers that arrived while it held the context. That broke three
things: a REST/web prompt got stuck 'running' (no terminal turn event), a
background-task/cron steer was silently lost (null was read as 'buffered'
but nothing was), and a follow-up prompt could land in the window after
isCompacting cleared but before reminders were reinjected.
Reuse the existing defer-and-replay model instead of rejecting:
- steer() and launch() buffer into steerBuffer while a compaction is in
progress (returning null = buffered), mirroring how an active turn defers
input.
- FullCompaction.compactionWorker keeps isCompacting true through
refreshSystemPrompt + injectAfterCompaction (moving markCompleted and the
completed event after reinjection), then replays the buffer via
TurnFlow.onCompactionFinished — on success, on an A1 prefix/tail cancel,
and on failure/abort.
- onCompactionFinished flushes into an active turn if one exists, else
launches a fresh turn from the deferred input.
No PromptService change: a deferred prompt's eventual turn.started lets it
associate the pending prompt and clear it on turn.ended.
* feat(kosong): detect tool_use/tool_result adjacency errors
Add isToolExchangeAdjacencyError to classify the strict-provider 400 raised
when an assistant tool_use is not correctly paired with its tool_result
(missing, stray, or non-adjacent), excluding context-overflow 400s. Lets the
agent loop recognize the error and resend a wire-compliant request instead of
leaving the session stuck.
* fix(agent-core): close mid-history orphan tool calls and resend wire-compliant after a strict 400
Strict providers (Anthropic) reject a request whose assistant tool_use is not
answered by an adjacent tool_result, and the same malformed history is re-sent
every turn, permanently bricking the session.
- Projector now closes a mid-history tool call whose result is missing entirely
(a later turn proves it is not in-flight) with a synthetic result; the
trailing in-flight call is still left untouched.
- Add a strict projection (synthesize every open call, drop stray results) and,
on a tool_use/tool_result adjacency 400, resend the request once with it.
- Report every projection repair (reorder / synthesize / drop) via log and
telemetry, deduped by signature, so a silently-mangled history leaves a trace.
Trailing-tail synthesis (expected under compaction) is not flagged.
* fix(kosong): merge consecutive user turns for strict providers
Gemini/Vertex require strictly alternating user/model turns and reject
consecutive user turns with HTTP 400. They arise after compaction (kept
prompts + user-role summary + injected reminders) and when a turn is
steered in right after a tool result. Anthropic already merged them
inline; the Google converter did not, so post-compaction requests failed.
Extract the asymmetric merge into a shared mergeConsecutiveUserMessages
helper applied at each strict provider's conversion boundary: refactor
Anthropic to use it (behavior unchanged) and apply it at the Google
converter's exit. A conformance suite drives every strict provider with
the post-compaction shape and a steer-after-tool-result shape, asserting
no consecutive same-role turns reach the wire, so a new strict provider
cannot silently omit the merge.
The provider-agnostic projector stays structure-preserving: lenient
providers (OpenAI/Kimi) keep distinct turns for clearer message
boundaries; only strict providers normalize, where the requirement lives.
* feat(kosong): recognize the broader structural request-rejection family
Add isRecoverableRequestStructureError, covering the strict-provider 400s that
stem from a malformed message array re-sent every turn: tool_use/tool_result
pairing, empty/whitespace-only text blocks, a non-user first message, and
non-alternating roles. Context-overflow 400s are excluded (handled by
compaction). Lets the loop trigger one strict, wire-compliant resend for the
whole family rather than only tool-pairing errors.
* fix(agent-core): sanitize whitespace and strict-resend structural 400s, with diagnostics
- Drop empty AND whitespace-only text blocks in projection (Anthropic rejects
whitespace-only with "text content blocks must contain non-whitespace text",
which otherwise sticks a session); treat whitespace-only tool output as empty.
- Broaden the post-400 strict resend to the whole structural family and add two
strict-only passes to the strict projection: drop leading non-user messages
(first message must be user) and merge consecutive assistant turns.
- Log + telemetry for every wire repair the projector applies (reorder,
synthesize, drop orphan, drop leading, merge assistants, drop whitespace),
deduped by signature; log the strict resend outcome (recovered or still
rejected) so a stuck session always leaves a trace.
* fix(agent-core): normalize empty-equivalent tool result arrays to the empty placeholder
A tool result whose ContentPart[] output has no sendable content (an empty array,
or only empty/whitespace-only text blocks) was returned verbatim, so projection
stripped the blank blocks, left the tool message empty, and threw on every send —
bricking the session locally. String outputs were already normalized; do the same
for arrays. A non-text part or any non-whitespace text still keeps the real
output.
* chore(changeset): simplify the wire-compliance changeset
1240 lines
39 KiB
TypeScript
1240 lines
39 KiB
TypeScript
import { Readable, type Writable } from 'node:stream';
|
|
|
|
import type { KaosProcess } from '@moonshot-ai/kaos';
|
|
import type { Message } from '@moonshot-ai/kosong';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { renderNotificationXml } from '../../src/agent/context/notification-xml';
|
|
import { project } from '../../src/agent/context/projector';
|
|
import type { ContextMessage } from '../../src/agent/context/types';
|
|
import { estimateTokensForMessages } from '../../src/utils/tokens';
|
|
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
|
|
import { testAgent } from './harness/agent';
|
|
|
|
describe('Agent context', () => {
|
|
it('stores prompt origins without leaking them to LLM projection', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'hello' }]);
|
|
ctx.agent.context.appendSystemReminder('Remember this.', { kind: 'injection', variant: 'host' });
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: { type: 'step.begin', uuid: 'origin-step', turnId: '', step: 1 },
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.call',
|
|
uuid: 'origin-tool',
|
|
turnId: '',
|
|
step: 1,
|
|
stepUuid: 'origin-step',
|
|
toolCallId: 'call_origin',
|
|
name: 'Run',
|
|
args: {},
|
|
},
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: { type: 'step.end', uuid: 'origin-step', turnId: '', step: 1 },
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.result',
|
|
parentUuid: 'origin-tool',
|
|
toolCallId: 'call_origin',
|
|
result: { output: 'tool output' },
|
|
},
|
|
});
|
|
|
|
expect(ctx.agent.context.history.map(({ role, origin }) => ({ role, origin }))).toEqual([
|
|
{ role: 'user', origin: { kind: 'user' } },
|
|
{ role: 'user', origin: { kind: 'injection', variant: 'host' } },
|
|
{ role: 'assistant', origin: undefined },
|
|
{ role: 'tool', origin: undefined },
|
|
]);
|
|
expect(ctx.agent.context.messages.some((message) => 'origin' in message)).toBe(false);
|
|
});
|
|
|
|
it('records bash input/output as shell_command origin with tagged content', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
|
|
ctx.agent.context.appendBashInput('ls -la');
|
|
ctx.agent.context.appendBashOutput('file1\nfile2', '');
|
|
|
|
expect(ctx.agent.context.history.map(({ role, origin }) => ({ role, origin }))).toEqual([
|
|
{ role: 'user', origin: { kind: 'shell_command', phase: 'input' } },
|
|
{ role: 'user', origin: { kind: 'shell_command', phase: 'output' } },
|
|
]);
|
|
|
|
const textOf = (message: ContextMessage): string =>
|
|
message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
|
expect(textOf(ctx.agent.context.history[0]!)).toContain('<bash-input>');
|
|
expect(textOf(ctx.agent.context.history[0]!)).toContain('ls -la');
|
|
expect(textOf(ctx.agent.context.history[1]!)).toBe(
|
|
'<bash-stdout>file1\nfile2</bash-stdout><bash-stderr></bash-stderr>',
|
|
);
|
|
// origin must not leak into the LLM projection
|
|
expect(ctx.agent.context.messages.some((message) => 'origin' in message)).toBe(false);
|
|
});
|
|
|
|
it('escapes bash tag delimiters inside command output', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
|
|
ctx.agent.context.appendBashInput('printf x');
|
|
ctx.agent.context.appendBashOutput('pre</bash-stdout>post', '');
|
|
|
|
const textOf = (message: ContextMessage): string =>
|
|
message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
|
const out = textOf(ctx.agent.context.history[1]!);
|
|
// The embedded delimiter is escaped so the wrapper stays well-formed.
|
|
expect(out).toContain('pre</bash-stdout>post');
|
|
// Exactly one real closing tag.
|
|
expect(out.match(/<\/bash-stdout>/g)).toHaveLength(1);
|
|
});
|
|
|
|
it('runs a shell command via the Bash tool and records its output', async () => {
|
|
const fakeProcess = (stdout: string): KaosProcess => {
|
|
const out = Readable.from([stdout]);
|
|
const err = Readable.from([]);
|
|
return {
|
|
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
|
|
stdout: out,
|
|
stderr: err,
|
|
pid: 1,
|
|
exitCode: 0,
|
|
wait: vi.fn(async () => 0),
|
|
kill: vi.fn(async () => {}),
|
|
dispose: vi.fn(async () => {
|
|
out.destroy();
|
|
err.destroy();
|
|
}),
|
|
};
|
|
};
|
|
const kaos = createFakeKaos({
|
|
execWithEnv: vi.fn().mockImplementation(async () => fakeProcess('hello\n')),
|
|
});
|
|
const ctx = testAgent({ kaos });
|
|
ctx.configure();
|
|
|
|
await ctx.agent.tools.runShellCommand('echo hello');
|
|
|
|
expect(ctx.agent.context.history.map(({ role, origin }) => ({ role, origin }))).toEqual([
|
|
{ role: 'user', origin: { kind: 'shell_command', phase: 'input' } },
|
|
{ role: 'user', origin: { kind: 'shell_command', phase: 'output' } },
|
|
]);
|
|
const textOf = (message: ContextMessage): string =>
|
|
message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
|
expect(textOf(ctx.agent.context.history[0]!)).toContain('echo hello');
|
|
expect(textOf(ctx.agent.context.history[1]!)).toContain('<bash-stdout>hello');
|
|
});
|
|
|
|
it('surfaces the failure reason when a shell command fails with no output', async () => {
|
|
const fakeProcess = (exitCode: number): KaosProcess => {
|
|
const out = Readable.from([]);
|
|
const err = Readable.from([]);
|
|
return {
|
|
stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable,
|
|
stdout: out,
|
|
stderr: err,
|
|
pid: 1,
|
|
exitCode,
|
|
wait: vi.fn(async () => exitCode),
|
|
kill: vi.fn(async () => {}),
|
|
dispose: vi.fn(async () => {
|
|
out.destroy();
|
|
err.destroy();
|
|
}),
|
|
};
|
|
};
|
|
const kaos = createFakeKaos({
|
|
execWithEnv: vi.fn().mockImplementation(async () => fakeProcess(1)),
|
|
});
|
|
const ctx = testAgent({ kaos });
|
|
ctx.configure();
|
|
|
|
const result = await ctx.agent.tools.runShellCommand('false');
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.stderr).toContain('exit code');
|
|
const textOf = (message: ContextMessage): string =>
|
|
message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
|
const output = ctx.agent.context.history.at(-1)!;
|
|
expect(textOf(output)).toContain('<bash-stderr>');
|
|
expect(textOf(output)).toContain('exit code');
|
|
});
|
|
|
|
it('normalizes a whitespace-only array tool result to the empty-output placeholder', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: { type: 'step.begin', uuid: 's1', turnId: 't', step: 1 },
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.call',
|
|
uuid: 'call_ws',
|
|
turnId: 't',
|
|
step: 1,
|
|
stepUuid: 's1',
|
|
toolCallId: 'call_ws',
|
|
name: 'Run',
|
|
args: {},
|
|
},
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.result',
|
|
parentUuid: 'call_ws',
|
|
toolCallId: 'call_ws',
|
|
// Array (ContentPart[]) output whose only block is whitespace. The tool
|
|
// contract allows arbitrary content arrays (e.g. MCP tools), so this must
|
|
// be normalized to the empty placeholder rather than left to be stripped
|
|
// empty by projection (which would throw on every send).
|
|
result: { output: [{ type: 'text', text: ' \n' }] },
|
|
},
|
|
});
|
|
|
|
expect(() => ctx.agent.context.messages).not.toThrow();
|
|
expect(ctx.agent.context.messages).toMatchObject([
|
|
{ role: 'assistant', toolCalls: [{ id: 'call_ws' }] },
|
|
{
|
|
role: 'tool',
|
|
content: [{ type: 'text', text: '<system>Tool output is empty.</system>' }],
|
|
toolCallId: 'call_ws',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('renders tool error and empty-output status as model-visible text', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: { type: 'step.begin', uuid: 's1', turnId: 't', step: 1 },
|
|
});
|
|
for (const toolCallId of ['call_error', 'call_empty']) {
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.call',
|
|
uuid: toolCallId,
|
|
turnId: 't',
|
|
step: 1,
|
|
stepUuid: 's1',
|
|
toolCallId,
|
|
name: 'Run',
|
|
args: {},
|
|
},
|
|
});
|
|
}
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.result',
|
|
parentUuid: 'call_error',
|
|
toolCallId: 'call_error',
|
|
result: { output: 'permission denied', isError: true },
|
|
},
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.result',
|
|
parentUuid: 'call_empty',
|
|
toolCallId: 'call_empty',
|
|
result: { output: '' },
|
|
},
|
|
});
|
|
|
|
expect(ctx.agent.context.messages).toMatchObject([
|
|
{ role: 'assistant', toolCalls: [{ id: 'call_error' }, { id: 'call_empty' }] },
|
|
{
|
|
role: 'tool',
|
|
content: [
|
|
{ type: 'text', text: '<system>ERROR: Tool execution failed.</system>\npermission denied' },
|
|
],
|
|
toolCallId: 'call_error',
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: [{ type: 'text', text: '<system>Tool output is empty.</system>' }],
|
|
toolCallId: 'call_empty',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('drops empty and whitespace-only text parts in LLM projection', () => {
|
|
const history: ContextMessage[] = [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: '' },
|
|
{ type: 'text', text: 'Run the tool' },
|
|
],
|
|
toolCalls: [],
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: '' }],
|
|
toolCalls: [],
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: '' }],
|
|
toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: [{ type: 'text', text: 'result' }],
|
|
toolCalls: [],
|
|
toolCallId: 'call_empty',
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'think', think: '', encrypted: 'enc_empty_thinking' }],
|
|
toolCalls: [],
|
|
},
|
|
{
|
|
// Whitespace-only message: strict providers reject the block, so the
|
|
// whole message is dropped from the projection.
|
|
role: 'user',
|
|
content: [{ type: 'text', text: ' ' }],
|
|
toolCalls: [],
|
|
},
|
|
];
|
|
|
|
expect(project(history)).toEqual([
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'Run the tool' }],
|
|
toolCalls: [],
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: [],
|
|
toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: [{ type: 'text', text: 'result' }],
|
|
toolCalls: [],
|
|
toolCallId: 'call_empty',
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'think', think: '', encrypted: 'enc_empty_thinking' }],
|
|
toolCalls: [],
|
|
},
|
|
]);
|
|
expect(history[0]?.content).toEqual([
|
|
{ type: 'text', text: '' },
|
|
{ type: 'text', text: 'Run the tool' },
|
|
]);
|
|
expect(history[1]?.content).toEqual([{ type: 'text', text: '' }]);
|
|
});
|
|
|
|
it('rejects tool result messages left empty by LLM projection cleanup', () => {
|
|
const history: ContextMessage[] = [
|
|
{
|
|
role: 'assistant',
|
|
content: [],
|
|
toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }],
|
|
},
|
|
{
|
|
role: 'tool',
|
|
content: [{ type: 'text', text: '' }],
|
|
toolCallId: 'call_empty',
|
|
toolCalls: [],
|
|
},
|
|
];
|
|
|
|
expect(() => project(history)).toThrow(
|
|
'Tool result message content cannot be empty after removing empty text blocks.',
|
|
);
|
|
});
|
|
|
|
it('projects hook result messages into LLM projection', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'hooked input' }]);
|
|
ctx.agent.context.appendMessage({
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '<hook_result hook_event="UserPromptSubmit">\nhook response\n</hook_result>',
|
|
},
|
|
],
|
|
toolCalls: [],
|
|
origin: { kind: 'hook_result', event: 'UserPromptSubmit' },
|
|
});
|
|
ctx.agent.context.appendMessage({
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>',
|
|
},
|
|
],
|
|
toolCalls: [],
|
|
origin: { kind: 'hook_result', event: 'UserPromptSubmit', blocked: true },
|
|
});
|
|
ctx.agent.context.appendMessage({
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'continue from stop hook' }],
|
|
toolCalls: [],
|
|
origin: { kind: 'hook_result', event: 'Stop' },
|
|
});
|
|
|
|
expect(ctx.agent.context.history).toHaveLength(4);
|
|
expect(ctx.agent.context.messages).toEqual([
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'hooked input' }],
|
|
toolCalls: [],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '<hook_result hook_event="UserPromptSubmit">\nhook response\n</hook_result>',
|
|
},
|
|
],
|
|
toolCalls: [],
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>',
|
|
},
|
|
],
|
|
toolCalls: [],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'continue from stop hook' }],
|
|
toolCalls: [],
|
|
},
|
|
]);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
it('projects blocked UserPromptSubmit prompts into LLM projection', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'blocked prompt' }]);
|
|
ctx.agent.context.appendMessage({
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>',
|
|
},
|
|
],
|
|
toolCalls: [],
|
|
origin: { kind: 'hook_result', event: 'UserPromptSubmit', blocked: true },
|
|
});
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'safe followup' }]);
|
|
|
|
expect(ctx.agent.context.history).toHaveLength(3);
|
|
expect(ctx.agent.context.messages).toEqual([
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'blocked prompt' }],
|
|
toolCalls: [],
|
|
},
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: '<hook_result hook_event="UserPromptSubmit">\nblocked reason\n</hook_result>',
|
|
},
|
|
],
|
|
toolCalls: [],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'safe followup' }],
|
|
toolCalls: [],
|
|
},
|
|
]);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
it('projects user, assistant, tool call, and tool result records into LLM history', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
ctx.appendAssistantText(1, 'earlier assistant');
|
|
ctx.appendToolExchange();
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'done' });
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'continue' }] });
|
|
|
|
await ctx.untilTurnEnd();
|
|
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
|
system: <system-prompt>
|
|
tools: []
|
|
messages:
|
|
user: text "user before step 1"
|
|
assistant: text "earlier assistant"
|
|
user: text "lookup something"
|
|
assistant: text "I will call Lookup." calls call_lookup:Lookup { "query": "moon" }
|
|
tool[call_lookup]: text "lookup result"
|
|
user: text "continue"
|
|
`);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
it('keeps system reminders separate from real user prompts', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
ctx.agent.context.appendSystemReminder('Remember the host note.', {
|
|
kind: 'injection',
|
|
variant: 'host',
|
|
});
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'noted' });
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Real user prompt' }] });
|
|
|
|
await ctx.untilTurnEnd();
|
|
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
|
system: <system-prompt>
|
|
tools: []
|
|
messages:
|
|
user: text "<system-reminder>\\nRemember the host note.\\n</system-reminder>"
|
|
user: text "Real user prompt"
|
|
`);
|
|
});
|
|
|
|
it('defers system reminders until pending tool results are recorded and resumed', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
const stepUuid = 'skill-batch-step';
|
|
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'load a skill' }]);
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: { type: 'step.begin', uuid: stepUuid, turnId: '0', step: 1 },
|
|
});
|
|
for (const [toolCallId, name] of [
|
|
['call_write', 'Write'],
|
|
['call_skill', 'Skill'],
|
|
] as const) {
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.call',
|
|
uuid: toolCallId,
|
|
turnId: '0',
|
|
step: 1,
|
|
stepUuid,
|
|
toolCallId,
|
|
name,
|
|
args: {},
|
|
},
|
|
});
|
|
}
|
|
|
|
ctx.dispatch({
|
|
type: 'context.append_message',
|
|
message: {
|
|
role: 'user',
|
|
content: [{ type: 'text', text: '<system-reminder>\nskill body\n</system-reminder>' }],
|
|
toolCalls: [],
|
|
origin: {
|
|
kind: 'skill_activation',
|
|
activationId: 'act_skill',
|
|
skillName: 'demo',
|
|
trigger: 'model-tool',
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(ctx.agent.context.history.map((message) => message.role)).toEqual(['user', 'assistant']);
|
|
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'step.end',
|
|
uuid: stepUuid,
|
|
turnId: '0',
|
|
step: 1,
|
|
finishReason: 'tool_use',
|
|
},
|
|
});
|
|
expect(ctx.agent.context.history.map((message) => message.role)).toEqual(['user', 'assistant']);
|
|
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.result',
|
|
parentUuid: 'call_write',
|
|
toolCallId: 'call_write',
|
|
result: { output: 'wrote file' },
|
|
},
|
|
});
|
|
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
|
|
'user',
|
|
'assistant',
|
|
'tool',
|
|
]);
|
|
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.result',
|
|
parentUuid: 'call_skill',
|
|
toolCallId: 'call_skill',
|
|
result: { output: 'skill loaded' },
|
|
},
|
|
});
|
|
|
|
expect(ctx.agent.context.messages.map((message) => message.role)).toEqual([
|
|
'user',
|
|
'assistant',
|
|
'tool',
|
|
'tool',
|
|
'user',
|
|
]);
|
|
expect(ctx.agent.context.messages[4]?.content).toEqual([
|
|
{ type: 'text', text: '<system-reminder>\nskill body\n</system-reminder>' },
|
|
]);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
// Regression: a user message injected after `step.begin` but before the first
|
|
// `tool.call` (e.g. a background-task notification flushed mid-step) lands
|
|
// between the assistant `tool_use` and its `tool_result` in history, which
|
|
// strict providers (Anthropic) reject with HTTP 400. The projector must repair
|
|
// the adjacency so the `tool_result` immediately follows the `tool_use`. Micro
|
|
// compaction exposed this latent misordering by busting the prompt cache.
|
|
it('repairs a tool_use/tool_result adjacency broken by an injected user message', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
const stepUuid = 'mid-step-notify-step';
|
|
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'drive the tank' }]);
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: { type: 'step.begin', uuid: stepUuid, turnId: '0', step: 1 },
|
|
});
|
|
|
|
// Notification arrives in the gap between step.begin and tool.call, when no
|
|
// tool result is yet pending, so it is pushed directly into history.
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: '<notification>bg done</notification>' }], {
|
|
kind: 'background_task',
|
|
taskId: 'task-1',
|
|
status: 'completed',
|
|
notificationId: 'task:task-1:completed',
|
|
});
|
|
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.call',
|
|
uuid: 'call_drive',
|
|
turnId: '0',
|
|
step: 1,
|
|
stepUuid,
|
|
toolCallId: 'call_drive',
|
|
name: 'Drive',
|
|
args: {},
|
|
},
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'step.end',
|
|
uuid: stepUuid,
|
|
turnId: '0',
|
|
step: 1,
|
|
finishReason: 'tool_use',
|
|
},
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.result',
|
|
parentUuid: 'call_drive',
|
|
toolCallId: 'call_drive',
|
|
result: { output: 'drove forward' },
|
|
},
|
|
});
|
|
|
|
// History preserves the original (misordered) sequence: the notification sits
|
|
// between the assistant tool_use and its tool_result.
|
|
expect(ctx.agent.context.history.map((message) => message.role)).toEqual([
|
|
'user',
|
|
'assistant',
|
|
'user',
|
|
'tool',
|
|
]);
|
|
|
|
// Projection repairs the adjacency: the tool_result immediately follows the
|
|
// assistant tool_use, and the sandwiched notification is moved after it.
|
|
const projected = ctx.agent.context.messages;
|
|
expect(projected.map((message) => message.role)).toEqual(['user', 'assistant', 'tool', 'user']);
|
|
const assistantIndex = projected.findIndex(
|
|
(message) => message.role === 'assistant' && message.toolCalls.length > 0,
|
|
);
|
|
expect(projected[assistantIndex]?.toolCalls.map((toolCall) => toolCall.id)).toEqual([
|
|
'call_drive',
|
|
]);
|
|
expect(projected[assistantIndex + 1]).toMatchObject({
|
|
role: 'tool',
|
|
toolCallId: 'call_drive',
|
|
});
|
|
expect(projected[assistantIndex + 2]?.content).toEqual([
|
|
{ type: 'text', text: '<notification>bg done</notification>' },
|
|
]);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
it('drops deferred reminders when compaction drops a pending tool exchange', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'old prompt' }]);
|
|
ctx.appendContextPartiallyResolvedParallelToolExchange();
|
|
|
|
ctx.agent.context.appendSystemReminder('first reminder', {
|
|
kind: 'injection',
|
|
variant: 'host',
|
|
});
|
|
ctx.agent.context.applyCompaction({
|
|
summary: 'summary of old prompt',
|
|
compactedCount: 4,
|
|
tokensBefore: 100,
|
|
});
|
|
ctx.agent.context.appendSystemReminder('second reminder', {
|
|
kind: 'injection',
|
|
variant: 'host',
|
|
});
|
|
|
|
// Compaction keeps only the real user prompt plus the summary; the deferred
|
|
// first reminder is dropped because initial context is rebuilt every turn.
|
|
// The second reminder, appended after compaction, is preserved.
|
|
expect(ctx.agent.context.messages.map((message) => message.role)).toEqual([
|
|
'user',
|
|
'user',
|
|
'user',
|
|
]);
|
|
expect(ctx.agent.context.messages[2]?.content).toEqual([
|
|
{ type: 'text', text: '<system-reminder>\nsecond reminder\n</system-reminder>' },
|
|
]);
|
|
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.result',
|
|
parentUuid: 'call_open_two',
|
|
toolCallId: 'call_open_two',
|
|
result: { output: 'two result' },
|
|
},
|
|
});
|
|
|
|
// The pending tool exchange was dropped by compaction, so the late tool
|
|
// result is ignored and the history is unchanged.
|
|
expect(ctx.agent.context.messages.map((message) => message.role)).toEqual([
|
|
'user',
|
|
'user',
|
|
'user',
|
|
]);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
it('applyCompaction keeps only real user input from mixed user-role history', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'real prompt' }]);
|
|
ctx.agent.context.appendBashInput('pwd');
|
|
ctx.agent.context.appendBashOutput('/tmp/repo', '', false);
|
|
ctx.agent.context.appendLocalCommandStdout('local command output');
|
|
ctx.agent.context.appendSystemReminder('stale reminder', {
|
|
kind: 'injection',
|
|
variant: 'host',
|
|
});
|
|
|
|
const result = ctx.agent.context.applyCompaction({
|
|
summary: 'summary of mixed history',
|
|
compactedCount: 5,
|
|
tokensBefore: 100,
|
|
});
|
|
ctx.agent.context.appendSystemReminder('fresh reminder', {
|
|
kind: 'injection',
|
|
variant: 'host',
|
|
});
|
|
|
|
expect(ctx.agent.context.history.map(({ role, origin }) => ({ role, origin }))).toEqual([
|
|
{ role: 'user', origin: { kind: 'user' } },
|
|
{ role: 'user', origin: { kind: 'compaction_summary' } },
|
|
{ role: 'user', origin: { kind: 'injection', variant: 'host' } },
|
|
]);
|
|
expect(result.keptUserMessageCount).toBe(1);
|
|
});
|
|
|
|
it('clears context before the next LLM request', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'stale user message' }]);
|
|
await ctx.rpc.clearContext({});
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'fresh' });
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'fresh prompt' }] });
|
|
|
|
await ctx.untilTurnEnd();
|
|
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
|
system: <system-prompt>
|
|
tools: []
|
|
messages:
|
|
user: text "fresh prompt"
|
|
`);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
it('uses compacted summary plus recent messages', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'old user message' }]);
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'recent user message' }]);
|
|
ctx.agent.context.applyCompaction({
|
|
summary: 'summary of old context',
|
|
compactedCount: 1,
|
|
tokensBefore: 100,
|
|
});
|
|
expect(ctx.agent.context.history.at(-1)?.origin).toEqual({ kind: 'compaction_summary' });
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'after compaction' });
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'new prompt' }] });
|
|
|
|
await ctx.untilTurnEnd();
|
|
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
|
system: <system-prompt>
|
|
tools: []
|
|
messages:
|
|
user: text "old user message\\n\\nrecent user message"
|
|
user: text "summary of old context"
|
|
user: text "new prompt"
|
|
`);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
it('includes new user messages as pending until the next usage update', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
ctx.appendAssistantTextWithUsage(1, 'previous answer', 1_000);
|
|
expect(ctx.agent.context.tokenCountWithPending).toBe(1_000);
|
|
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'next user prompt'.repeat(20) }]);
|
|
|
|
const pendingMessages = ctx.agent.context.history.slice(-1);
|
|
expect(ctx.agent.context.tokenCountWithPending).toBe(
|
|
ctx.agent.context.tokenCount + estimateTokensForMessages(pendingMessages),
|
|
);
|
|
});
|
|
|
|
it('keeps tool results pending when step usage covers only through the assistant message', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
const stepUuid = 'context-pending-tool-step';
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'lookup pending tokens' }]);
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: { type: 'step.begin', uuid: stepUuid, turnId: '0', step: 1 },
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.call',
|
|
uuid: 'call_pending_tokens',
|
|
turnId: '0',
|
|
step: 1,
|
|
stepUuid,
|
|
toolCallId: 'call_pending_tokens',
|
|
name: 'Lookup',
|
|
args: {},
|
|
},
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'tool.result',
|
|
parentUuid: 'call_pending_tokens',
|
|
toolCallId: 'call_pending_tokens',
|
|
result: { output: 'large tool result '.repeat(50) },
|
|
},
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'step.end',
|
|
uuid: stepUuid,
|
|
turnId: '0',
|
|
step: 1,
|
|
usage: {
|
|
inputOther: 1_200,
|
|
output: 80,
|
|
inputCacheRead: 0,
|
|
inputCacheCreation: 0,
|
|
},
|
|
finishReason: 'tool_use',
|
|
},
|
|
});
|
|
|
|
const pendingMessages = ctx.agent.context.history.slice(-1);
|
|
expect(ctx.agent.context.tokenCount).toBe(1_280);
|
|
expect(ctx.agent.context.tokenCountWithPending).toBe(
|
|
1_280 + estimateTokensForMessages(pendingMessages),
|
|
);
|
|
});
|
|
|
|
it('does not zero tokenCount when a filtered step reports zero usage', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
ctx.appendAssistantTextWithUsage(1, 'previous answer', 1_000);
|
|
expect(ctx.agent.context.tokenCount).toBe(1_000);
|
|
|
|
const stepUuid = 'context-filtered-step';
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'next prompt' }]);
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: { type: 'step.begin', uuid: stepUuid, turnId: '0', step: 2 },
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_loop_event',
|
|
event: {
|
|
type: 'step.end',
|
|
uuid: stepUuid,
|
|
turnId: '0',
|
|
step: 2,
|
|
usage: {
|
|
inputOther: 0,
|
|
output: 0,
|
|
inputCacheRead: 0,
|
|
inputCacheCreation: 0,
|
|
},
|
|
finishReason: 'filtered',
|
|
},
|
|
});
|
|
|
|
expect(ctx.agent.context.tokenCount).toBeGreaterThan(1_000);
|
|
expect(ctx.agent.context.tokenCountWithPending).toBeGreaterThanOrEqual(
|
|
ctx.agent.context.tokenCount,
|
|
);
|
|
});
|
|
|
|
it('undo only counts real user prompts, skipping background notifications', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
|
|
ctx.appendAssistantText(1, 'first response');
|
|
ctx.appendAssistantText(2, 'second response');
|
|
|
|
// Append a background task notification (role: 'user' but not a real prompt)
|
|
ctx.agent.context.appendMessage({
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'background task completed' }],
|
|
toolCalls: [],
|
|
origin: {
|
|
kind: 'background_task',
|
|
taskId: 'bash-001',
|
|
status: 'completed',
|
|
notificationId: 'task:bash-001:completed',
|
|
},
|
|
});
|
|
|
|
expect(ctx.agent.context.history.map((m) => m.role)).toEqual([
|
|
'user',
|
|
'assistant',
|
|
'user',
|
|
'assistant',
|
|
'user',
|
|
]);
|
|
|
|
ctx.agent.context.undo(1);
|
|
|
|
// Should remove the background notification, the second assistant, and the second user prompt
|
|
expect(ctx.agent.context.history.map((m) => m.role)).toEqual(['user', 'assistant']);
|
|
});
|
|
|
|
it('stops at compaction summary and records the requested undo count', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'old user message' }]);
|
|
ctx.agent.context.applyCompaction({
|
|
summary: 'summary of compacted context',
|
|
compactedCount: 1,
|
|
tokensBefore: 100,
|
|
});
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'recent user message' }]);
|
|
ctx.agent.context.appendMessage({
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'recent answer' }],
|
|
toolCalls: [],
|
|
});
|
|
ctx.newEvents();
|
|
|
|
expect(() => {
|
|
ctx.agent.context.undo(2);
|
|
}).toThrow(
|
|
'Cannot undo 2 prompts; only 1 prompt can be undone in the active context after the last compaction.',
|
|
);
|
|
|
|
expect(ctx.agent.context.history).toEqual([
|
|
expect.objectContaining({
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'old user message' }],
|
|
}),
|
|
expect.objectContaining({
|
|
role: 'user',
|
|
origin: { kind: 'compaction_summary' },
|
|
content: [{ type: 'text', text: 'summary of compacted context' }],
|
|
}),
|
|
]);
|
|
expect(ctx.newEvents()).toContainEqual(
|
|
expect.objectContaining({
|
|
type: '[wire]',
|
|
event: 'context.undo',
|
|
args: expect.objectContaining({ count: 2 }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not throw while restoring an undo that stops at compaction summary', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'old user message' }]);
|
|
ctx.agent.context.applyCompaction({
|
|
summary: 'summary of compacted context',
|
|
compactedCount: 1,
|
|
tokensBefore: 100,
|
|
});
|
|
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'recent user message' }]);
|
|
ctx.agent.context.appendMessage({
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'recent answer' }],
|
|
toolCalls: [],
|
|
});
|
|
|
|
expect(() => {
|
|
ctx.agent.records.restore({ type: 'context.undo', count: 2 });
|
|
}).not.toThrow();
|
|
expect(ctx.agent.context.history).toEqual([
|
|
expect.objectContaining({
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'old user message' }],
|
|
}),
|
|
expect.objectContaining({
|
|
role: 'user',
|
|
origin: { kind: 'compaction_summary' },
|
|
content: [{ type: 'text', text: 'summary of compacted context' }],
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('preserves injection messages when undo removes the surrounding turn', () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
|
|
ctx.dispatch({
|
|
type: 'context.append_message',
|
|
message: userMessage('do the work', { kind: 'user' }),
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_message',
|
|
message: userMessage('Plan mode is active', {
|
|
kind: 'injection',
|
|
variant: 'plan_mode',
|
|
}),
|
|
});
|
|
ctx.dispatch({
|
|
type: 'context.append_message',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'work done' }],
|
|
toolCalls: [],
|
|
},
|
|
});
|
|
|
|
ctx.agent.context.undo(1);
|
|
|
|
expect(ctx.agent.context.history).toEqual([
|
|
expect.objectContaining({
|
|
role: 'user',
|
|
origin: { kind: 'injection', variant: 'plan_mode' },
|
|
}),
|
|
]);
|
|
expect(ctx.agent.replayBuilder.buildResult()).toEqual([
|
|
expect.objectContaining({
|
|
type: 'message',
|
|
message: expect.objectContaining({
|
|
origin: { kind: 'injection', variant: 'plan_mode' },
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
});
|
|
|
|
describe('Agent context notification projection', () => {
|
|
it('renders task notifications with escaped attributes and generic children', () => {
|
|
const text = renderNotificationXml({
|
|
id: 'n_"1&2',
|
|
category: 'task',
|
|
type: 'task.done',
|
|
source_kind: 'background_task',
|
|
source_id: 'bg&1',
|
|
title: 'Task finished',
|
|
severity: 'info',
|
|
body: 'The task completed.',
|
|
children: [
|
|
[
|
|
'<output-file path="/tmp/logs/a&b/output.log" bytes="1234">',
|
|
'Read the output file to retrieve the result: /tmp/logs/a&b/output.log',
|
|
'</output-file>',
|
|
].join('\n'),
|
|
],
|
|
});
|
|
|
|
expect(text).toContain('id="n_"1&2"');
|
|
expect(text).toContain('source_id="bg&1"');
|
|
expect(text).toContain('Title: Task finished');
|
|
expect(text).toContain('Severity: info');
|
|
expect(text).toContain('<output-file path="/tmp/logs/a&b/output.log" bytes="1234">');
|
|
expect(text).toContain(
|
|
'Read the output file to retrieve the result: /tmp/logs/a&b/output.log',
|
|
);
|
|
expect(text).not.toContain('<task-notification>');
|
|
expect(text.trimEnd()).toMatch(/<\/notification>$/);
|
|
});
|
|
|
|
it('renders an agent_id attribute when the notification carries one', () => {
|
|
// Background agent tasks (taskId starts with `agent-`) own a separate
|
|
// `agent_id` for the spawned subagent. Surfacing it as a top-level XML
|
|
// attribute lets the LLM resume the right thing without having to dig
|
|
// it out of the body or cross-reference the spawn-success ToolResult.
|
|
const text = renderNotificationXml({
|
|
id: 'n_lost1',
|
|
category: 'task',
|
|
type: 'task.lost',
|
|
source_kind: 'background_task',
|
|
source_id: 'agent-w7gq3wwj',
|
|
agent_id: 'agent-0',
|
|
title: 'Background agent lost',
|
|
severity: 'warning',
|
|
body: 'Background agent 1 lost.',
|
|
});
|
|
|
|
expect(text).toContain('source_id="agent-w7gq3wwj"');
|
|
expect(text).toContain('agent_id="agent-0"');
|
|
});
|
|
|
|
it('omits the agent_id attribute when the notification does not carry one', () => {
|
|
const text = renderNotificationXml({
|
|
id: 'n_bash',
|
|
category: 'task',
|
|
type: 'task.completed',
|
|
source_kind: 'background_task',
|
|
source_id: 'bash-abcdef00',
|
|
title: 'Background task completed',
|
|
severity: 'info',
|
|
body: 'echo done completed.',
|
|
});
|
|
|
|
expect(text).not.toContain('agent_id=');
|
|
});
|
|
|
|
it('does not render task output blocks for non-task notifications', () => {
|
|
const text = renderNotificationXml({
|
|
id: '',
|
|
source_kind: 'host',
|
|
output_path: '/tmp/output.log',
|
|
});
|
|
|
|
expect(text).toContain('id="unknown"');
|
|
expect(text).toContain('category="unknown"');
|
|
expect(text).not.toContain('<task-notification>');
|
|
expect(text).not.toContain('<output-file');
|
|
expect(text).not.toContain('/tmp/output.log');
|
|
});
|
|
|
|
it('does not merge a cron-fire envelope into an adjacent user message', () => {
|
|
const cronEnvelope =
|
|
'<cron-fire jobId="deadbeef" cron="*/5 * * * *" recurring="true" coalescedCount="1" stale="false">\n<prompt>\ncheck the deploy\n</prompt>\n</cron-fire>';
|
|
const messages = project([
|
|
userMessage(cronEnvelope, {
|
|
kind: 'cron_job',
|
|
jobId: 'deadbeef',
|
|
cron: '*/5 * * * *',
|
|
recurring: true,
|
|
coalescedCount: 1,
|
|
stale: false,
|
|
}),
|
|
userMessage('Actual follow-up from the user', { kind: 'user' }),
|
|
]);
|
|
expect(messages).toHaveLength(2);
|
|
expect(textOf(messages[0]!)).toBe(cronEnvelope);
|
|
expect(textOf(messages[1]!)).toBe('Actual follow-up from the user');
|
|
});
|
|
|
|
it('uses message origin to keep non-user-origin messages separate', () => {
|
|
const messages = project([
|
|
userMessage('Host reminder without an XML prefix', {
|
|
kind: 'injection',
|
|
variant: 'host',
|
|
}),
|
|
userMessage('Actual follow-up from the user', { kind: 'user' }),
|
|
]);
|
|
|
|
expect(messages).toHaveLength(2);
|
|
expect(textOf(messages[0]!)).toBe('Host reminder without an XML prefix');
|
|
expect(textOf(messages[1]!)).toBe('Actual follow-up from the user');
|
|
});
|
|
|
|
it('only merges user-role messages with user origin', () => {
|
|
const messages = project([
|
|
userMessage('First real prompt', { kind: 'user' }),
|
|
userMessage('Second real prompt', { kind: 'user' }),
|
|
userMessage('No origin prompt'),
|
|
userMessage('Third real prompt', { kind: 'user' }),
|
|
]);
|
|
|
|
expect(messages).toHaveLength(3);
|
|
expect(textOf(messages[0]!)).toBe('First real prompt\n\nSecond real prompt');
|
|
expect(textOf(messages[1]!)).toBe('No origin prompt');
|
|
expect(textOf(messages[2]!)).toBe('Third real prompt');
|
|
});
|
|
});
|
|
|
|
function userMessage(text: string, origin?: ContextMessage['origin']): ContextMessage {
|
|
return {
|
|
role: 'user',
|
|
content: [{ type: 'text', text }],
|
|
toolCalls: [],
|
|
origin,
|
|
};
|
|
}
|
|
|
|
function textOf(message: Message): string {
|
|
return message.content
|
|
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
.map((part) => part.text)
|
|
.join('');
|
|
}
|