From 18263a6b23d8ae1971df13e17b79a9090aadc9d1 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sun, 16 Aug 2026 22:23:03 +0800 Subject: [PATCH] fix: keep local message IDs out of model requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct-model conversation history began carrying GoodBuddy UUIDs after durable context compression was added. OpenAI Responses rejected follow-up messages because provider message IDs must start with msg_, and the same metadata could also reach other protocol payloads. Model request serialization now strips local IDs across Anthropic Messages, Chat Completions, and Responses while retaining provider IDs during Responses tool rounds. Regression coverage includes compression, tools, and a gated real-model follow-up. Release note: 修复默认直连模型在继续对话或重新编辑发送时可能因消息 ID 格式错误而失败的问题。 --- src/main/agent/model-runtime.test.ts | 154 +++++++++++++++++++++- src/main/agent/model-runtime.ts | 23 +++- src/main/agent/runtime-e2e.manual.test.ts | 49 +++++++ 3 files changed, 221 insertions(+), 5 deletions(-) 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 () => {