diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index 7cb1794..d41b1cf 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -273,6 +273,102 @@ describe('ModelAgentRuntime', () => { expect(events.at(-1)).toMatchObject({ type: 'done' }) }) + it.each([ + { + protocol: 'anthropic-messages' as const, + payloadKey: 'messages' as const, + response: () => + new Response(createEventStream('history accepted'), { + status: 200, + headers: { 'content-type': 'text/event-stream' } + }) + }, + { + protocol: 'openai-chat-completions' as const, + payloadKey: 'messages' as const, + response: () => + new Response( + [ + 'data: {"choices":[{"delta":{"content":"history accepted"}}]}', + '', + 'data: [DONE]', + '', + '' + ].join('\n'), + { + status: 200, + headers: { 'content-type': 'text/event-stream' } + } + ) + }, + { + protocol: 'openai-responses' as const, + payloadKey: 'input' as const, + response: () => + new Response(createResponsesEventStream('history accepted'), { + status: 200, + headers: { 'content-type': 'text/event-stream' } + }) + } + ])( + 'omits local message IDs from $protocol history', + async ({ protocol, payloadKey, response }) => { + const localMessageIds = [ + '00000000-0000-4000-8000-000000000011', + '00000000-0000-4000-8000-000000000012' + ] + const fetcher = vi.fn(async () => response()) + const runtime = new ModelAgentRuntime({ + apiKey: 'test-key', + baseUrl: 'https://model.example/v1', + model: 'test-model', + protocol, + authentication: 'api-key', + fetcher + }) + + try { + for await (const _event of runtime.run( + { + requestId: '00000000-0000-4000-8000-000000000010', + conversationId: 'conversation-local-message-ids', + prompt: 'current question', + history: [ + { role: 'user', content: 'earlier question' }, + { role: 'assistant', content: 'earlier answer' } + ], + historyMessageIds: localMessageIds + }, + new AbortController().signal + )) { + void _event + } + } finally { + await runtime.dispose() + } + + const body = JSON.parse( + fetcher.mock.calls[0]?.[1]?.body as string + ) as Record + const messages = body[payloadKey] as Array< + Record + > + expect( + messages.filter( + (message) => + message.role === 'user' || + message.role === 'assistant' + ) + ).toEqual([ + { role: 'user', content: 'earlier question' }, + { role: 'assistant', content: 'earlier answer' }, + { role: 'user', content: 'current question' } + ]) + expect(JSON.stringify(body)).not.toContain(localMessageIds[0]) + expect(JSON.stringify(body)).not.toContain(localMessageIds[1]) + } + ) + it('performs a real minimal request when testing the connection', async () => { const fetcher = vi.fn(async () => Response.json({ @@ -429,6 +525,14 @@ describe('ModelAgentRuntime', () => { content: `new-assistant-${'f'.repeat(16_000)}` } ] + const historyMessageIds = [ + '00000000-0000-4000-8000-000000000221', + '00000000-0000-4000-8000-000000000222', + '00000000-0000-4000-8000-000000000223', + '00000000-0000-4000-8000-000000000224', + '00000000-0000-4000-8000-000000000225', + '00000000-0000-4000-8000-000000000226' + ] const events: RuntimeEvent[] = [] for await (const event of runtime.run( @@ -436,7 +540,8 @@ describe('ModelAgentRuntime', () => { requestId: 'a431666e-5ec8-45e6-beb4-654132eed222', conversationId: 'conversation-compressed', prompt: '继续', - history + history, + historyMessageIds }, new AbortController().signal )) { @@ -451,6 +556,9 @@ describe('ModelAgentRuntime', () => { expect(summaryBody.system).toContain('Summarize earlier history.') expect(JSON.stringify(summaryBody.messages)).toContain('old-user-') expect(JSON.stringify(summaryBody.messages)).not.toContain('new-user-') + expect(JSON.stringify(summaryBody)).not.toContain( + historyMessageIds[0] + ) const answerBody = JSON.parse( fetcher.mock.calls[1]![1]!.body as string @@ -459,6 +567,9 @@ describe('ModelAgentRuntime', () => { expect(answerMessages).toContain('压缩后的摘要') expect(answerMessages).toContain('new-user-') expect(answerMessages).not.toContain('old-user-') + for (const id of historyMessageIds) { + expect(answerMessages).not.toContain(id) + } expect(events).toContainEqual( expect.objectContaining({ type: 'context-compression', @@ -473,6 +584,8 @@ describe('ModelAgentRuntime', () => { estimatedAfterTokens: expect.any(Number), conversationState: expect.objectContaining({ coveredMessageCount: 4, + coveredFromMessageId: historyMessageIds[0], + coveredThroughMessageId: historyMessageIds[3], coveredHistoryDigest: expect.stringMatching(/^[0-9a-f]{64}$/u), summary: '压缩后的摘要' }) @@ -3351,6 +3464,14 @@ describe('ModelAgentRuntime', () => { requestId: 'a431666e-5ec8-45e6-beb4-654132eed134', conversationId: 'conversation-responses-tools', prompt: '读取 README', + history: [ + { role: 'user', content: '此前问题' }, + { role: 'assistant', content: '此前回答' } + ], + historyMessageIds: [ + '00000000-0000-4000-8000-000000000341', + '00000000-0000-4000-8000-000000000342' + ], workMode: 'execute' }, new AbortController().signal, @@ -3365,6 +3486,20 @@ describe('ModelAgentRuntime', () => { expect(firstBody).toMatchObject({ model: 'gpt-5', stream: true, + input: [ + { + role: 'user', + content: '此前问题' + }, + { + role: 'assistant', + content: '此前回答' + }, + { + role: 'user', + content: '读取 README' + } + ], tools: [ { type: 'function', @@ -3379,6 +3514,14 @@ describe('ModelAgentRuntime', () => { ) as Record expect(secondBody).toMatchObject({ input: [ + { + role: 'user', + content: '此前问题' + }, + { + role: 'assistant', + content: '此前回答' + }, { role: 'user', content: '读取 README' @@ -3448,9 +3591,16 @@ describe('ModelAgentRuntime', () => { } ]) for (const [, init] of fetcher.mock.calls) { - expect(JSON.parse(init?.body as string)).not.toHaveProperty( + const body = JSON.parse(init?.body as string) + expect(body).not.toHaveProperty( 'previous_response_id' ) + expect(JSON.stringify(body)).not.toContain( + '00000000-0000-4000-8000-000000000341' + ) + expect(JSON.stringify(body)).not.toContain( + '00000000-0000-4000-8000-000000000342' + ) } expect( events diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index 58ccb45..e228414 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -64,6 +64,17 @@ type ConversationMessage = { content: string } +type ProviderConversationMessage = Pick< + ConversationMessage, + 'role' | 'content' +> + +function toProviderConversationMessages( + messages: readonly ConversationMessage[] +): ProviderConversationMessage[] { + return messages.map(({ role, content }) => ({ role, content })) +} + type ConversationSummaryState = { coveredHistoryDigest: string coveredMessageCount: number @@ -2225,7 +2236,9 @@ export class ModelAgentRuntime implements AgentRuntime { private getAnthropicMessages( request: AgentExecutionRequest ): AnthropicApiMessage[] { - const history = this.getConversationHistory(request) + const history = toProviderConversationMessages( + this.getConversationHistory(request) + ) const content: AnthropicApiMessage['content'] = request.images && request.images.length > 0 ? [ @@ -2256,7 +2269,9 @@ export class ModelAgentRuntime implements AgentRuntime { request: AgentExecutionRequest, system: string ): Array> { - const history = this.getConversationHistory(request) + const history = toProviderConversationMessages( + this.getConversationHistory(request) + ) const userContent = request.images && request.images.length > 0 ? [ @@ -2282,7 +2297,9 @@ export class ModelAgentRuntime implements AgentRuntime { private getResponsesInput( request: AgentExecutionRequest ): Array> { - const history = this.getConversationHistory(request) + const history = toProviderConversationMessages( + this.getConversationHistory(request) + ) const userContent = request.images && request.images.length > 0 ? [ diff --git a/src/main/agent/runtime-e2e.manual.test.ts b/src/main/agent/runtime-e2e.manual.test.ts index dcd4151..e751bc0 100644 --- a/src/main/agent/runtime-e2e.manual.test.ts +++ b/src/main/agent/runtime-e2e.manual.test.ts @@ -323,6 +323,55 @@ describe.runIf(enabled)('runtime end-to-end', () => { 120_000 ) + it( + 'continues real direct-model history with local message IDs', + async () => { + const runtime = new ModelAgentRuntime({ + apiKey, + baseUrl, + model: modelName, + protocol, + authentication: 'api-key', + maxOutputTokens: 128 + }) + + try { + const output = await collectText( + runtime.run( + { + requestId: crypto.randomUUID(), + conversationId: crypto.randomUUID(), + workMode: 'ask', + prompt: + 'Return exactly this text and nothing else: LOCAL_HISTORY_ID_E2E_OK', + history: [ + { + role: 'user', + content: + 'The required verification text is LOCAL_HISTORY_ID_E2E_OK.' + }, + { + role: 'assistant', + content: + 'I will return that verification text when asked.' + } + ], + historyMessageIds: [ + crypto.randomUUID(), + crypto.randomUUID() + ] + }, + new AbortController().signal + ) + ) + expect(output).toContain('LOCAL_HISTORY_ID_E2E_OK') + } finally { + await runtime.dispose() + } + }, + 120_000 + ) + it( 'counts a real image in provider-reported input usage', async () => {