test(agent-core): consolidate test helpers into AgentTestContext (#106)

This commit is contained in:
_Kerman 2026-05-27 14:41:45 +08:00 committed by GitHub
parent d599183c8e
commit d1c381f38a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 504 additions and 461 deletions

View file

@ -104,9 +104,9 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'old user two', 'old assistant two', 40);
appendExchange(ctx, 3, 'recent user three', 'recent assistant three', 120);
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'old user two', 'old assistant two', 40);
ctx.appendExchange(3, 'recent user three', 'recent assistant three', 120);
const compacted = new Promise<void>((resolve) => {
ctx.emitter.once('context.apply_compaction', () => {
resolve();
@ -140,7 +140,7 @@ describe('Agent compaction', () => {
assistant: text "old assistant two"
user: text <compaction-instruction>
`);
expect(compactHistory(ctx)).toMatchInlineSnapshot(`
expect(ctx.compactHistory()).toMatchInlineSnapshot(`
[
{
"role": "assistant",
@ -235,7 +235,7 @@ describe('Agent compaction', () => {
max_context_tokens: maxContextTokens,
},
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', maxContextTokens - 100);
ctx.appendExchange(1, 'old user one', 'old assistant one', maxContextTokens - 100);
const compacted = new Promise<void>((resolve) => {
ctx.emitter.once('context.apply_compaction', () => {
resolve();
@ -254,12 +254,12 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: 'empty-placeholder', turnId: '', step: 2 },
});
appendExchange(ctx, 3, 'old user two', 'old assistant two', 40);
ctx.appendExchange(3, 'old user two', 'old assistant two', 40);
const compacted = new Promise<void>((resolve) => {
ctx.emitter.once('context.apply_compaction', () => {
resolve();
@ -313,9 +313,9 @@ describe('Agent compaction', () => {
ctx.configure();
await ctx.rpc.setModel({ model: 'kimi-code' });
ctx.newEvents();
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'recent user two', 'recent assistant two', 80);
const outcome = onceAny(ctx, ['context.apply_compaction', 'error']);
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const outcome = ctx.onceAny(['context.apply_compaction', 'error']);
await ctx.rpc.beginCompaction({});
@ -334,21 +334,21 @@ describe('Agent compaction', () => {
);
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
expect(tokenCalls).toEqual([undefined, undefined, true]);
expect(compactHistory(ctx)).toEqual([
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'old user one' },
{ role: 'assistant', text: 'old assistant one' },
{ role: 'user', text: 'recent user two' },
{ role: 'assistant', text: 'recent assistant two' },
]);
const retryOutcome = onceAny(ctx, ['context.apply_compaction', 'error']);
const retryOutcome = ctx.onceAny(['context.apply_compaction', 'error']);
await ctx.rpc.beginCompaction({});
expect(await retryOutcome).toBe('context.apply_compaction');
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token', 'fresh-token']);
expect(tokenCalls).toEqual([undefined, undefined, true, undefined]);
expect(compactHistory(ctx)).toEqual([
expect(ctx.compactHistory()).toEqual([
{ role: 'assistant', text: 'Recovered compacted summary.' },
{ role: 'user', text: 'recent user two' },
{ role: 'assistant', text: 'recent assistant two' },
@ -373,10 +373,10 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'old user two', 'old assistant two', 40);
appendExchange(ctx, 3, 'recent user three', 'recent assistant three', 120);
const compacted = once(ctx, 'context.apply_compaction');
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'old user two', 'old assistant two', 40);
ctx.appendExchange(3, 'recent user three', 'recent assistant three', 120);
const compacted = ctx.once('context.apply_compaction');
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' });
ctx.agent.fullCompaction.begin({ source: 'auto', instruction: undefined });
@ -426,14 +426,14 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'recent user two', 'recent assistant two', 80);
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
ctx.agent.fullCompaction.begin({ source: 'manual', instruction: undefined });
await vi.waitFor(() => {
expect(preCompactSignal).toBeInstanceOf(AbortSignal);
});
const canceled = once(ctx, 'compaction.cancelled');
const canceled = ctx.once('compaction.cancelled');
ctx.agent.fullCompaction.cancel();
await canceled;
@ -463,9 +463,9 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'recent user two', 'recent assistant two', 80);
const compacted = once(ctx, 'context.apply_compaction');
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('context.apply_compaction');
await ctx.rpc.beginCompaction({});
await compacted;
@ -499,9 +499,9 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'recent user two', 'recent assistant two', 80);
const compacted = once(ctx, 'context.apply_compaction');
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('context.apply_compaction');
await ctx.rpc.beginCompaction({});
await firstEmptySummary.promise;
@ -509,7 +509,7 @@ describe('Agent compaction', () => {
await compacted;
expect(attempts).toBe(3);
expect(compactHistory(ctx)).toEqual([
expect(ctx.compactHistory()).toEqual([
{ role: 'assistant', text: 'Recovered compacted summary.' },
{ role: 'user', text: 'recent user two' },
{ role: 'assistant', text: 'recent assistant two' },
@ -541,9 +541,9 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'recent user two', 'recent assistant two', 80);
const compacted = once(ctx, 'context.apply_compaction');
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('context.apply_compaction');
await ctx.rpc.beginCompaction({});
await firstAttemptFailed.promise;
@ -574,9 +574,9 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'recent user two', 'recent assistant two', 80);
const cancelled = once(ctx, 'compaction.cancelled');
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const cancelled = ctx.once('compaction.cancelled');
await ctx.rpc.beginCompaction({});
await firstAttemptFailed.promise;
@ -599,9 +599,9 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'recent user two', 'recent assistant two', 80);
const failed = once(ctx, 'error');
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await failed;
@ -615,7 +615,7 @@ describe('Agent compaction', () => {
]),
);
expect(eventIndex(events, 'compaction.cancelled')).toBeLessThan(eventIndex(events, 'error'));
expect(compactHistory(ctx)).toEqual([
expect(ctx.compactHistory()).toEqual([
{ role: 'user', text: 'old user one' },
{ role: 'assistant', text: 'old assistant one' },
{ role: 'user', text: 'recent user two' },
@ -649,9 +649,9 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'recent user two', 'recent assistant two', 80);
const failed = once(ctx, 'error');
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const failed = ctx.once('error');
await ctx.rpc.beginCompaction({});
await failed;
@ -676,7 +676,7 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendRichToolExchange(ctx);
ctx.appendRichToolExchange();
const compacted = new Promise<void>((resolve) => {
ctx.emitter.once('context.apply_compaction', () => {
resolve();
@ -724,9 +724,9 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendPartiallyResolvedParallelToolExchange(ctx);
const compacted = once(ctx, 'context.apply_compaction');
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendPartiallyResolvedParallelToolExchange();
const compacted = ctx.once('context.apply_compaction');
ctx.mockNextResponse({ type: 'text', text: 'Compacted before open tools.' });
await ctx.rpc.beginCompaction({ instruction: 'Keep stable facts.' });
@ -755,9 +755,9 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'recent user two', 'recent assistant two', 80);
const compacted = once(ctx, 'context.apply_compaction');
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('context.apply_compaction');
ctx.mockNextResponse({ type: 'text', text: 'Compacted prefix.' });
await ctx.rpc.beginCompaction({});
@ -785,7 +785,7 @@ describe('Agent compaction', () => {
assistant: text "old assistant one"
user: text <compaction-instruction>
`);
expect(compactHistory(ctx)).toMatchInlineSnapshot(`
expect(ctx.compactHistory()).toMatchInlineSnapshot(`
[
{
"role": "assistant",
@ -814,9 +814,9 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'recent user two', 'recent assistant two', 80);
const canceled = once(ctx, 'full_compaction.cancel');
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const canceled = ctx.once('full_compaction.cancel');
ctx.mockNextResponse({ type: 'text', text: 'Stale summary.' });
await ctx.rpc.beginCompaction({});
@ -843,7 +843,7 @@ describe('Agent compaction', () => {
assistant: text "old assistant one"
user: text <compaction-instruction>
`);
expect(compactHistory(ctx)).toMatchInlineSnapshot(`[]`);
expect(ctx.compactHistory()).toMatchInlineSnapshot(`[]`);
await ctx.expectResumeMatches();
});
@ -854,9 +854,9 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 100);
appendExchange(ctx, 2, 'old user two', 'old assistant two', 200);
appendExchange(ctx, 3, 'recent user three', 'recent assistant three', 950_000);
ctx.appendExchange(1, 'old user one', 'old assistant one', 100);
ctx.appendExchange(2, 'old user two', 'old assistant two', 200);
ctx.appendExchange(3, 'recent user three', 'recent assistant three', 950_000);
ctx.mockNextResponse({ type: 'text', text: 'Auto compacted summary.' });
ctx.mockNextResponse({ type: 'text', text: 'I can answer after compaction.' });
@ -947,7 +947,7 @@ describe('Agent compaction', () => {
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'only pending user' }]);
const canceled = once(ctx, 'compaction.cancelled');
const canceled = ctx.once('compaction.cancelled');
await ctx.rpc.beginCompaction({});
await canceled;
@ -955,16 +955,16 @@ describe('Agent compaction', () => {
expect(ctx.llmCalls).toHaveLength(0);
ctx.agent.context.clear();
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
appendExchange(ctx, 2, 'recent user two', 'recent assistant two', 80);
const compacted = once(ctx, 'context.apply_compaction');
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80);
const compacted = ctx.once('context.apply_compaction');
ctx.mockNextResponse({ type: 'text', text: 'Compacted after no-op cancel.' });
await ctx.rpc.beginCompaction({});
await compacted;
expect(ctx.llmCalls).toHaveLength(1);
expect(compactHistory(ctx)).toEqual([
expect(ctx.compactHistory()).toEqual([
{ role: 'assistant', text: 'Compacted after no-op cancel.' },
{ role: 'user', text: 'recent user two' },
{ role: 'assistant', text: 'recent assistant two' },
@ -989,7 +989,7 @@ describe('Agent compaction', () => {
max_context_tokens: 32_000,
},
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 1_000);
ctx.appendExchange(1, 'old user one', 'old assistant one', 1_000);
ctx.mockNextResponse({ type: 'text', text: 'I can answer without reserved compaction.' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'small prompt' }] });
@ -1019,7 +1019,7 @@ describe('Agent compaction', () => {
max_context_tokens: 2_000,
},
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 1_400);
ctx.appendExchange(1, 'old user one', 'old assistant one', 1_400);
ctx.mockNextResponse({ type: 'text', text: 'Reserved compacted summary.' });
ctx.mockNextResponse({ type: 'text', text: 'I can answer after reserved compaction.' });
@ -1042,7 +1042,7 @@ describe('Agent compaction', () => {
max_context_tokens: 2_000,
},
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 1_650);
ctx.appendExchange(1, 'old user one', 'old assistant one', 1_650);
const oversizedPrompt = `keep-this-pending-verbatim:${'x'.repeat(1_800)}`;
ctx.mockNextResponse({ type: 'text', text: 'Oversized prompt summary.' });
@ -1069,7 +1069,7 @@ describe('Agent compaction', () => {
max_context_tokens: 1_000_000,
},
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 840_000);
ctx.appendExchange(1, 'old user one', 'old assistant one', 840_000);
const pendingPrompt = `ratio-pending-verbatim:${'x'.repeat(60_000)}`;
ctx.mockNextResponse({ type: 'text', text: 'Ratio compacted summary.' });
@ -1114,7 +1114,7 @@ describe('Agent compaction', () => {
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry after provider overflow' }] });
@ -1197,7 +1197,7 @@ describe('Agent compaction', () => {
: { ...resolved, modelCapabilities: UNKNOWN_CAPABILITY };
};
expect(ctx.agent.config.modelCapabilities.max_context_tokens).toBe(0);
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.newEvents();
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry without known model window' }] });
@ -1243,7 +1243,7 @@ describe('Agent compaction', () => {
max_context_tokens: 2_000,
},
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
const oversizedPrompt = `uncompactable-pending:${'x'.repeat(9_000)}`;
ctx.newEvents();
@ -1297,7 +1297,7 @@ describe('Agent compaction', () => {
max_context_tokens: 14,
},
});
appendExchange(ctx, 1, 'old user one', 'old assistant one', 1);
ctx.appendExchange(1, 'old user one', 'old assistant one', 1);
const promptThatFitsWithoutPlaceholder = 'x'.repeat(40);
ctx.newEvents();
@ -1388,24 +1388,6 @@ afterEach(() => {
vi.useRealTimers();
});
function once(ctx: TestAgentContext, type: string): Promise<void> {
return new Promise((resolve) => {
ctx.emitter.once(type, () => {
resolve();
});
});
}
function onceAny(ctx: TestAgentContext, types: readonly string[]): Promise<string> {
return new Promise((resolve) => {
for (const type of types) {
ctx.emitter.once(type, () => {
resolve(type);
});
}
});
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
@ -1468,51 +1450,6 @@ function textResult(text: string): Awaited<ReturnType<GenerateFn>> {
};
}
function appendExchange(
ctx: TestAgentContext,
step: number,
userText: string,
assistantText: string,
tokenTotal: number,
) {
const stepUuid = `step-${String(step)}`;
ctx.agent.context.appendUserMessage([{ type: 'text', text: userText }]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: `part-${String(step)}`,
turnId: '',
step,
stepUuid,
part: {
type: 'text',
text: assistantText,
},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step,
usage: {
inputOther: tokenTotal - 1,
output: 1,
inputCacheRead: 0,
inputCacheCreation: 0,
},
finishReason: 'end_turn',
},
});
}
const alwaysCompactOnce: CompactionStrategy = {
shouldCompact: () => true,
shouldBlock: () => true,
@ -1530,13 +1467,6 @@ function missingToolCall(): ToolCall {
};
}
function compactHistory(ctx: TestAgentContext) {
return ctx.agent.context.history.map((message) => ({
role: message.role,
text: message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''),
}));
}
function testCompactionStrategy(): DefaultCompactionStrategy {
return new DefaultCompactionStrategy({
triggerRatio: 0.85,
@ -1604,135 +1534,3 @@ function inputHistorySnapshot(history: readonly Message[]): string[] {
function normalizeInputText(text: string): string {
return text.includes('compact this conversation context') ? '<compaction-instruction>' : text;
}
function appendRichToolExchange(ctx: TestAgentContext) {
const stepUuid = 'rich-step';
ctx.agent.context.appendUserMessage([
{ type: 'text', text: 'inspect this image' },
{ type: 'image_url', imageUrl: { url: 'ms://image-1', id: 'image-1' } },
]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 1 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: 'rich-think',
turnId: '',
step: 1,
stepUuid,
part: {
type: 'think',
think: 'checking metadata',
},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: 'rich-text',
turnId: '',
step: 1,
stepUuid,
part: {
type: 'text',
text: 'I will call Lookup.',
},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'rich-tool-call',
turnId: '',
step: 1,
stepUuid,
toolCallId: 'call_lookup',
name: 'Lookup',
args: {
query: 'moon',
limit: 2,
},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step: 1,
usage: {
inputOther: 50,
output: 10,
inputCacheRead: 0,
inputCacheCreation: 0,
},
finishReason: 'tool_use',
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'rich-tool-call',
toolCallId: 'call_lookup',
result: {
output: [
{ type: 'text', text: 'lookup result' },
{ type: 'video_url', videoUrl: { url: 'ms://video-1', id: 'video-1' } },
],
},
},
});
}
function appendPartiallyResolvedParallelToolExchange(ctx: TestAgentContext) {
const stepUuid = 'partial-tool-step';
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'run both tools' }]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 2 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_open_one',
turnId: '',
step: 2,
stepUuid,
toolCallId: 'call_open_one',
name: 'LookupOne',
args: { query: 'one' },
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_open_two',
turnId: '',
step: 2,
stepUuid,
toolCallId: 'call_open_two',
name: 'LookupTwo',
args: { query: 'two' },
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_open_one',
toolCallId: 'call_open_one',
result: {
output: 'one result',
},
},
});
}

View file

@ -5,7 +5,6 @@ import { sliceCompleteMessages } from '../../src/agent/context/complete-slice';
import { renderNotificationXml } from '../../src/agent/context/notification-xml';
import { project } from '../../src/agent/context/projector';
import { estimateTokensForMessages } from '../../src/utils/tokens';
import type { TestAgentContext } from './harness/agent';
import { testAgent } from './harness/agent';
describe('Agent context', () => {
@ -159,8 +158,8 @@ describe('Agent context', () => {
it('projects user, assistant, tool call, and tool result records into LLM history', async () => {
const ctx = testAgent();
ctx.configure();
appendAssistantText(ctx, 1, 'earlier assistant');
appendToolExchange(ctx);
ctx.appendAssistantText(1, 'earlier assistant');
ctx.appendToolExchange();
ctx.mockNextResponse({ type: 'text', text: 'done' });
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'continue' }] });
@ -302,7 +301,7 @@ describe('Agent context', () => {
ctx.configure();
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'old prompt' }]);
appendPartiallyResolvedParallelToolExchange(ctx);
ctx.appendContextPartiallyResolvedParallelToolExchange();
ctx.agent.context.appendSystemReminder('first reminder', {
kind: 'injection',
@ -403,7 +402,7 @@ describe('Agent context', () => {
it('includes new user messages as pending until the next usage update', () => {
const ctx = testAgent();
ctx.configure();
appendAssistantTextWithUsage(ctx, 1, 'previous answer', 1_000);
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) }]);
@ -558,161 +557,6 @@ describe('Agent context notification projection', () => {
});
});
function appendAssistantText(ctx: TestAgentContext, step: number, text: string) {
appendAssistantTextWithUsage(ctx, step, text);
}
function appendAssistantTextWithUsage(
ctx: TestAgentContext,
step: number,
text: string,
tokenTotal?: number,
) {
const stepUuid = `context-step-${String(step)}`;
const usage =
tokenTotal === undefined
? undefined
: {
inputOther: tokenTotal - 1,
output: 1,
inputCacheRead: 0,
inputCacheCreation: 0,
};
ctx.agent.context.appendUserMessage([{ type: 'text', text: `user before step ${String(step)}` }]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: `context-part-${String(step)}`,
turnId: '',
step,
stepUuid,
part: {
type: 'text',
text,
},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step,
usage,
finishReason: 'end_turn',
},
});
}
function appendToolExchange(ctx: TestAgentContext) {
const stepUuid = 'context-tool-step';
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'lookup something' }]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 2 },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: 'context-tool-part',
turnId: '',
step: 2,
stepUuid,
part: {
type: 'text',
text: 'I will call Lookup.',
},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'context-tool-call',
turnId: '',
step: 2,
stepUuid,
toolCallId: 'call_lookup',
name: 'Lookup',
args: {
query: 'moon',
},
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step: 2,
finishReason: 'tool_use',
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'context-tool-call',
toolCallId: 'call_lookup',
result: { output: 'lookup result' },
},
});
}
function appendPartiallyResolvedParallelToolExchange(ctx: TestAgentContext) {
const stepUuid = 'context-partial-tool-step';
ctx.agent.context.appendUserMessage([{ type: 'text', text: 'run both tools' }]);
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 2 },
});
for (const [toolCallId, name] of [
['call_open_one', 'LookupOne'],
['call_open_two', 'LookupTwo'],
] as const) {
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: toolCallId,
turnId: '',
step: 2,
stepUuid,
toolCallId,
name,
args: {},
},
});
}
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step: 2,
finishReason: 'tool_use',
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_open_one',
toolCallId: 'call_open_one',
result: { output: 'one result' },
},
});
}
function userMessage(text: string): Message {
return {
role: 'user',

View file

@ -275,6 +275,436 @@ export class AgentTestContext {
}
}
once(type: string): Promise<void> {
return new Promise((resolve) => {
this.emitter.once(type, () => {
resolve();
});
});
}
onceAny(types: readonly string[]): Promise<string> {
return new Promise((resolve) => {
for (const type of types) {
this.emitter.once(type, () => {
resolve(type);
});
}
});
}
appendExchange(
step: number,
userText: string,
assistantText: string,
tokenTotal: number,
): void {
const stepUuid = `step-${String(step)}`;
this.agent.context.appendUserMessage([{ type: 'text', text: userText }]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step },
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: `part-${String(step)}`,
turnId: '',
step,
stepUuid,
part: {
type: 'text',
text: assistantText,
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step,
usage: {
inputOther: tokenTotal - 1,
output: 1,
inputCacheRead: 0,
inputCacheCreation: 0,
},
finishReason: 'end_turn',
},
});
}
appendAssistantText(step: number, text: string): void {
this.appendAssistantTextWithUsage(step, text);
}
appendAssistantTextWithUsage(step: number, text: string, tokenTotal?: number): void {
const stepUuid = `context-step-${String(step)}`;
const usage =
tokenTotal === undefined
? undefined
: {
inputOther: tokenTotal - 1,
output: 1,
inputCacheRead: 0,
inputCacheCreation: 0,
};
this.agent.context.appendUserMessage([
{ type: 'text', text: `user before step ${String(step)}` },
]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step },
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: `context-part-${String(step)}`,
turnId: '',
step,
stepUuid,
part: {
type: 'text',
text,
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step,
usage,
finishReason: 'end_turn',
},
});
}
appendAssistantTurn(step: number, text: string): void {
const stepUuid = `plan-injection-step-${String(step)}`;
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step },
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: `plan-injection-part-${String(step)}`,
turnId: '',
step,
stepUuid,
part: { type: 'text', text },
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step,
finishReason: 'end_turn',
},
});
}
appendToolExchange(): void {
const stepUuid = 'context-tool-step';
this.agent.context.appendUserMessage([{ type: 'text', text: 'lookup something' }]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 2 },
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: 'context-tool-part',
turnId: '',
step: 2,
stepUuid,
part: {
type: 'text',
text: 'I will call Lookup.',
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'context-tool-call',
turnId: '',
step: 2,
stepUuid,
toolCallId: 'call_lookup',
name: 'Lookup',
args: {
query: 'moon',
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step: 2,
finishReason: 'tool_use',
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'context-tool-call',
toolCallId: 'call_lookup',
result: { output: 'lookup result' },
},
});
}
appendUnresolvedToolExchange(resolvedToolResults: 0 | 1): void {
const stepUuid = `unresolved-tool-step-${String(resolvedToolResults)}`;
this.agent.context.appendUserMessage([{ type: 'text', text: 'run unresolved tools' }]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 2 },
});
for (const [toolCallId, name] of [
['call_unresolved_one', 'LookupOne'],
['call_unresolved_two', 'LookupTwo'],
] as const) {
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: toolCallId,
turnId: '',
step: 2,
stepUuid,
toolCallId,
name,
args: {},
},
});
}
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step: 2,
finishReason: 'tool_use',
},
});
if (resolvedToolResults === 1) {
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_unresolved_one',
toolCallId: 'call_unresolved_one',
result: { output: 'one result' },
},
});
}
}
appendRichToolExchange(): void {
const stepUuid = 'rich-step';
this.agent.context.appendUserMessage([
{ type: 'text', text: 'inspect this image' },
{ type: 'image_url', imageUrl: { url: 'ms://image-1', id: 'image-1' } },
]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 1 },
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: 'rich-think',
turnId: '',
step: 1,
stepUuid,
part: {
type: 'think',
think: 'checking metadata',
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: 'rich-text',
turnId: '',
step: 1,
stepUuid,
part: {
type: 'text',
text: 'I will call Lookup.',
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'rich-tool-call',
turnId: '',
step: 1,
stepUuid,
toolCallId: 'call_lookup',
name: 'Lookup',
args: {
query: 'moon',
limit: 2,
},
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step: 1,
usage: {
inputOther: 50,
output: 10,
inputCacheRead: 0,
inputCacheCreation: 0,
},
finishReason: 'tool_use',
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'rich-tool-call',
toolCallId: 'call_lookup',
result: {
output: [
{ type: 'text', text: 'lookup result' },
{ type: 'video_url', videoUrl: { url: 'ms://video-1', id: 'video-1' } },
],
},
},
});
}
appendContextPartiallyResolvedParallelToolExchange(): void {
const stepUuid = 'context-partial-tool-step';
this.agent.context.appendUserMessage([{ type: 'text', text: 'run both tools' }]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 2 },
});
for (const [toolCallId, name] of [
['call_open_one', 'LookupOne'],
['call_open_two', 'LookupTwo'],
] as const) {
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: toolCallId,
turnId: '',
step: 2,
stepUuid,
toolCallId,
name,
args: {},
},
});
}
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step: 2,
finishReason: 'tool_use',
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_open_one',
toolCallId: 'call_open_one',
result: { output: 'one result' },
},
});
}
appendPartiallyResolvedParallelToolExchange(): void {
const stepUuid = 'partial-tool-step';
this.agent.context.appendUserMessage([{ type: 'text', text: 'run both tools' }]);
this.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step: 2 },
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_open_one',
turnId: '',
step: 2,
stepUuid,
toolCallId: 'call_open_one',
name: 'LookupOne',
args: { query: 'one' },
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.call',
uuid: 'call_open_two',
turnId: '',
step: 2,
stepUuid,
toolCallId: 'call_open_two',
name: 'LookupTwo',
args: { query: 'two' },
},
});
this.dispatch({
type: 'context.append_loop_event',
event: {
type: 'tool.result',
parentUuid: 'call_open_one',
toolCallId: 'call_open_one',
result: {
output: 'one result',
},
},
});
}
compactHistory(): Array<{ readonly role: string; readonly text: string }> {
return this.agent.context.history.map((message) => ({
role: message.role,
text: message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''),
}));
}
async expectResumeMatches(): Promise<void> {
const resumed = testAgent({
runtime: {

View file

@ -536,8 +536,8 @@ describe('plan mode injection cadence', () => {
await ctx.agent.injection.inject();
expect(ctx.agent.context.history).toHaveLength(afterFull);
appendAssistantTurn(ctx, 1, 'assistant one');
appendAssistantTurn(ctx, 2, 'assistant two');
ctx.appendAssistantTurn(1, 'assistant one');
ctx.appendAssistantTurn(2, 'assistant two');
await ctx.agent.injection.inject();
expect(lastUserText(ctx.agent.context.history)).toContain('Plan mode still active');
@ -585,35 +585,6 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function appendAssistantTurn(ctx: ReturnType<typeof testAgent>, step: number, text: string): void {
const stepUuid = `plan-injection-step-${String(step)}`;
ctx.dispatch({
type: 'context.append_loop_event',
event: { type: 'step.begin', uuid: stepUuid, turnId: '', step },
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'content.part',
uuid: `plan-injection-part-${String(step)}`,
turnId: '',
step,
stepUuid,
part: { type: 'text', text },
},
});
ctx.dispatch({
type: 'context.append_loop_event',
event: {
type: 'step.end',
uuid: stepUuid,
turnId: '',
step,
finishReason: 'end_turn',
},
});
}
function lastUserText(history: readonly { role: string; content: readonly unknown[] }[]): string {
const message = history.findLast((item) => item.role === 'user');
if (message === undefined) return '';