fix: keep local message IDs out of model requests

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 格式错误而失败的问题。
This commit is contained in:
mesalogo
2026-08-16 22:23:03 +08:00
parent 80526c57bf
commit 18263a6b23
3 changed files with 221 additions and 5 deletions
+152 -2
View File
@@ -273,6 +273,102 @@ describe('ModelAgentRuntime', () => {
expect(events.at(-1)).toMatchObject({ type: 'done' }) 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<typeof fetch>(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<string, unknown>
const messages = body[payloadKey] as Array<
Record<string, unknown>
>
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 () => { it('performs a real minimal request when testing the connection', async () => {
const fetcher = vi.fn<typeof fetch>(async () => const fetcher = vi.fn<typeof fetch>(async () =>
Response.json({ Response.json({
@@ -429,6 +525,14 @@ describe('ModelAgentRuntime', () => {
content: `new-assistant-${'f'.repeat(16_000)}` 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[] = [] const events: RuntimeEvent[] = []
for await (const event of runtime.run( for await (const event of runtime.run(
@@ -436,7 +540,8 @@ describe('ModelAgentRuntime', () => {
requestId: 'a431666e-5ec8-45e6-beb4-654132eed222', requestId: 'a431666e-5ec8-45e6-beb4-654132eed222',
conversationId: 'conversation-compressed', conversationId: 'conversation-compressed',
prompt: '继续', prompt: '继续',
history history,
historyMessageIds
}, },
new AbortController().signal new AbortController().signal
)) { )) {
@@ -451,6 +556,9 @@ describe('ModelAgentRuntime', () => {
expect(summaryBody.system).toContain('Summarize earlier history.') expect(summaryBody.system).toContain('Summarize earlier history.')
expect(JSON.stringify(summaryBody.messages)).toContain('old-user-') expect(JSON.stringify(summaryBody.messages)).toContain('old-user-')
expect(JSON.stringify(summaryBody.messages)).not.toContain('new-user-') expect(JSON.stringify(summaryBody.messages)).not.toContain('new-user-')
expect(JSON.stringify(summaryBody)).not.toContain(
historyMessageIds[0]
)
const answerBody = JSON.parse( const answerBody = JSON.parse(
fetcher.mock.calls[1]![1]!.body as string fetcher.mock.calls[1]![1]!.body as string
@@ -459,6 +567,9 @@ describe('ModelAgentRuntime', () => {
expect(answerMessages).toContain('压缩后的摘要') expect(answerMessages).toContain('压缩后的摘要')
expect(answerMessages).toContain('new-user-') expect(answerMessages).toContain('new-user-')
expect(answerMessages).not.toContain('old-user-') expect(answerMessages).not.toContain('old-user-')
for (const id of historyMessageIds) {
expect(answerMessages).not.toContain(id)
}
expect(events).toContainEqual( expect(events).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: 'context-compression', type: 'context-compression',
@@ -473,6 +584,8 @@ describe('ModelAgentRuntime', () => {
estimatedAfterTokens: expect.any(Number), estimatedAfterTokens: expect.any(Number),
conversationState: expect.objectContaining({ conversationState: expect.objectContaining({
coveredMessageCount: 4, coveredMessageCount: 4,
coveredFromMessageId: historyMessageIds[0],
coveredThroughMessageId: historyMessageIds[3],
coveredHistoryDigest: expect.stringMatching(/^[0-9a-f]{64}$/u), coveredHistoryDigest: expect.stringMatching(/^[0-9a-f]{64}$/u),
summary: '压缩后的摘要' summary: '压缩后的摘要'
}) })
@@ -3351,6 +3464,14 @@ describe('ModelAgentRuntime', () => {
requestId: 'a431666e-5ec8-45e6-beb4-654132eed134', requestId: 'a431666e-5ec8-45e6-beb4-654132eed134',
conversationId: 'conversation-responses-tools', conversationId: 'conversation-responses-tools',
prompt: '读取 README', prompt: '读取 README',
history: [
{ role: 'user', content: '此前问题' },
{ role: 'assistant', content: '此前回答' }
],
historyMessageIds: [
'00000000-0000-4000-8000-000000000341',
'00000000-0000-4000-8000-000000000342'
],
workMode: 'execute' workMode: 'execute'
}, },
new AbortController().signal, new AbortController().signal,
@@ -3365,6 +3486,20 @@ describe('ModelAgentRuntime', () => {
expect(firstBody).toMatchObject({ expect(firstBody).toMatchObject({
model: 'gpt-5', model: 'gpt-5',
stream: true, stream: true,
input: [
{
role: 'user',
content: '此前问题'
},
{
role: 'assistant',
content: '此前回答'
},
{
role: 'user',
content: '读取 README'
}
],
tools: [ tools: [
{ {
type: 'function', type: 'function',
@@ -3379,6 +3514,14 @@ describe('ModelAgentRuntime', () => {
) as Record<string, unknown> ) as Record<string, unknown>
expect(secondBody).toMatchObject({ expect(secondBody).toMatchObject({
input: [ input: [
{
role: 'user',
content: '此前问题'
},
{
role: 'assistant',
content: '此前回答'
},
{ {
role: 'user', role: 'user',
content: '读取 README' content: '读取 README'
@@ -3448,9 +3591,16 @@ describe('ModelAgentRuntime', () => {
} }
]) ])
for (const [, init] of fetcher.mock.calls) { 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' '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( expect(
events events
+20 -3
View File
@@ -64,6 +64,17 @@ type ConversationMessage = {
content: string content: string
} }
type ProviderConversationMessage = Pick<
ConversationMessage,
'role' | 'content'
>
function toProviderConversationMessages(
messages: readonly ConversationMessage[]
): ProviderConversationMessage[] {
return messages.map(({ role, content }) => ({ role, content }))
}
type ConversationSummaryState = { type ConversationSummaryState = {
coveredHistoryDigest: string coveredHistoryDigest: string
coveredMessageCount: number coveredMessageCount: number
@@ -2225,7 +2236,9 @@ export class ModelAgentRuntime implements AgentRuntime {
private getAnthropicMessages( private getAnthropicMessages(
request: AgentExecutionRequest request: AgentExecutionRequest
): AnthropicApiMessage[] { ): AnthropicApiMessage[] {
const history = this.getConversationHistory(request) const history = toProviderConversationMessages(
this.getConversationHistory(request)
)
const content: AnthropicApiMessage['content'] = const content: AnthropicApiMessage['content'] =
request.images && request.images.length > 0 request.images && request.images.length > 0
? [ ? [
@@ -2256,7 +2269,9 @@ export class ModelAgentRuntime implements AgentRuntime {
request: AgentExecutionRequest, request: AgentExecutionRequest,
system: string system: string
): Array<Record<string, unknown>> { ): Array<Record<string, unknown>> {
const history = this.getConversationHistory(request) const history = toProviderConversationMessages(
this.getConversationHistory(request)
)
const userContent = const userContent =
request.images && request.images.length > 0 request.images && request.images.length > 0
? [ ? [
@@ -2282,7 +2297,9 @@ export class ModelAgentRuntime implements AgentRuntime {
private getResponsesInput( private getResponsesInput(
request: AgentExecutionRequest request: AgentExecutionRequest
): Array<Record<string, unknown>> { ): Array<Record<string, unknown>> {
const history = this.getConversationHistory(request) const history = toProviderConversationMessages(
this.getConversationHistory(request)
)
const userContent = const userContent =
request.images && request.images.length > 0 request.images && request.images.length > 0
? [ ? [
+49
View File
@@ -323,6 +323,55 @@ describe.runIf(enabled)('runtime end-to-end', () => {
120_000 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( it(
'counts a real image in provider-reported input usage', 'counts a real image in provider-reported input usage',
async () => { async () => {