diff --git a/packages/agent-core/test/agent/compaction.test.ts b/packages/agent-core/test/agent/compaction.test.ts index 4b951bb37..8b5189812 100644 --- a/packages/agent-core/test/agent/compaction.test.ts +++ b/packages/agent-core/test/agent/compaction.test.ts @@ -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((resolve) => { ctx.emitter.once('context.apply_compaction', () => { resolve(); @@ -140,7 +140,7 @@ describe('Agent compaction', () => { assistant: text "old assistant two" user: text `); - 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((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((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((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 `); - 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 `); - 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 { - return new Promise((resolve) => { - ctx.emitter.once(type, () => { - resolve(); - }); - }); -} - -function onceAny(ctx: TestAgentContext, types: readonly string[]): Promise { - return new Promise((resolve) => { - for (const type of types) { - ctx.emitter.once(type, () => { - resolve(type); - }); - } - }); -} - function deferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -1468,51 +1450,6 @@ function textResult(text: string): Awaited> { }; } -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') ? '' : 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', - }, - }, - }); -} diff --git a/packages/agent-core/test/agent/context.test.ts b/packages/agent-core/test/agent/context.test.ts index 41731b6f0..406183bfa 100644 --- a/packages/agent-core/test/agent/context.test.ts +++ b/packages/agent-core/test/agent/context.test.ts @@ -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', diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index 718730a3a..d44ddb9e2 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -275,6 +275,436 @@ export class AgentTestContext { } } + once(type: string): Promise { + return new Promise((resolve) => { + this.emitter.once(type, () => { + resolve(); + }); + }); + } + + onceAny(types: readonly string[]): Promise { + 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 { const resumed = testAgent({ runtime: { diff --git a/packages/agent-core/test/agent/plan.test.ts b/packages/agent-core/test/agent/plan.test.ts index fe34b695b..aa6c3b849 100644 --- a/packages/agent-core/test/agent/plan.test.ts +++ b/packages/agent-core/test/agent/plan.test.ts @@ -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 { return new Promise((resolve) => setTimeout(resolve, ms)); } -function appendAssistantTurn(ctx: ReturnType, 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 '';