mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-23 09:08:56 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24174698ed | |||
| 361a570802 | |||
| d44b793a91 | |||
| aa2e22b62d | |||
| 1b0435e464 | |||
| 28488a4e35 |
@@ -167,6 +167,22 @@ type HostedToolItem = HostedToolData & { readonly type: HostedToolType }
|
||||
const isHostedToolItem = (item: OpenResponses.StreamItem): item is HostedToolItem =>
|
||||
item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
|
||||
|
||||
const onHostedToolAdded = (state: OpenResponses.ParserState, item: HostedToolItem) => {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
return [
|
||||
{ ...state, lifecycle },
|
||||
[
|
||||
...events,
|
||||
LLMEvent.toolInputStart({
|
||||
id: item.id,
|
||||
name: HOSTED_TOOLS[item.type].name,
|
||||
providerExecuted: true,
|
||||
}),
|
||||
],
|
||||
] satisfies OpenResponses.StepResult
|
||||
}
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: HostedToolItem) {
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
@@ -224,6 +240,8 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.added" && event.item && isHostedToolItem(event.item))
|
||||
return Effect.succeed(onHostedToolAdded(state, event.item))
|
||||
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
|
||||
return onHostedToolDone(state, event.item)
|
||||
return OpenResponses.step(state, event)
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:anthropic-recovery",
|
||||
"provider:anthropic",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"recovery"
|
||||
],
|
||||
"name": "anthropic-recovery/accepts-a-synthetic-settled-tool-call-followed-by-continuation",
|
||||
"recordedAt": "2026-08-22T01:52:23.916Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://opencode.ai/zen/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-sonnet-4-6\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the weather in Paris.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I should use the weather tool.\"},{\"type\":\"tool_use\",\"id\":\"call_weather\",\"name\":\"lookup_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_weather\",\"content\":\"sunny\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"The previous response was interrupted. Continue from where you left off without repeating completed content.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"lookup_weather\",\"description\":\"Return benign weather data for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"none\"},\"stream\":true,\"max_tokens\":128,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_011CeGx6WkAQP5ULyRRbRwRw\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":664,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" weather in Paris is currently **sunny**! It looks like a great day to enjoy the city. ☀️\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":664,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":28} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\nevent: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:anthropic-recovery",
|
||||
"provider:anthropic",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"parallel",
|
||||
"recovery"
|
||||
],
|
||||
"name": "anthropic-recovery/accepts-synthetic-settled-parallel-tools-followed-by-continuation",
|
||||
"recordedAt": "2026-08-22T02:18:18.865Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://opencode.ai/zen/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"claude-sonnet-4-6\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the weather and local time in Paris.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I should use both lookup tools.\"},{\"type\":\"tool_use\",\"id\":\"call_weather\",\"name\":\"lookup_weather\",\"input\":{\"city\":\"Paris\"}},{\"type\":\"tool_use\",\"id\":\"call_time\",\"name\":\"lookup_time\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_weather\",\"content\":\"sunny\"},{\"type\":\"tool_result\",\"tool_use_id\":\"call_time\",\"content\":\"12:00\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"The previous response was interrupted. Continue from where you left off without repeating completed content.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"lookup_weather\",\"description\":\"Return benign weather data for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}},{\"name\":\"lookup_time\",\"description\":\"Return the benign local time for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"none\"},\"stream\":true,\"max_tokens\":128,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_011CeGz56HhX4x8LT1HGmLfn\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":814,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Here\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" are\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" the results for\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Paris:\\n\\n-\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" **Weather:**\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" It\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" is currently sunny in Paris.\\n- **Local Time:** The current local time in Paris is 12:00 PM (noon).\\n\\nLet me know if you need any other information!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":814,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":55} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\nevent: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+37
File diff suppressed because one or more lines are too long
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:bedrock-recovery",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:bedrock-converse",
|
||||
"tool",
|
||||
"parallel",
|
||||
"recovery",
|
||||
"bearer",
|
||||
"sad-path"
|
||||
],
|
||||
"name": "bedrock-recovery/rejects-continuation-with-a-missing-parallel-tool-result",
|
||||
"recordedAt": "2026-08-22T02:23:15.290Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-runtime.us-east-2.amazonaws.com/model/us.anthropic.claude-sonnet-4-6/converse-stream",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"modelId\":\"us.anthropic.claude-sonnet-4-6\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Look up the weather and local time in Paris.\"}]},{\"role\":\"assistant\",\"content\":[{\"text\":\"I should use both lookup tools.\"},{\"toolUse\":{\"toolUseId\":\"call_weather\",\"name\":\"lookup_weather\",\"input\":{\"city\":\"Paris\"}}},{\"toolUse\":{\"toolUseId\":\"call_time\",\"name\":\"lookup_time\",\"input\":{\"city\":\"Paris\"}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"call_weather\",\"content\":[{\"text\":\"sunny\"}],\"status\":\"success\"}},{\"text\":\"The previous response was interrupted. Continue from where you left off without repeating completed content.\"},{\"cachePoint\":{\"type\":\"default\"}}]}],\"inferenceConfig\":{\"maxTokens\":32,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"lookup_weather\",\"description\":\"Return benign weather data for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}}}},{\"toolSpec\":{\"name\":\"lookup_time\",\"description\":\"Return the benign local time for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}}}},{\"cachePoint\":{\"type\":\"default\"}}]}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 400,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"message\":\"Expected toolResult blocks at messages.2.content for the following Ids: call_time\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:gemini-recovery",
|
||||
"provider:google",
|
||||
"protocol:gemini",
|
||||
"tool",
|
||||
"recovery"
|
||||
],
|
||||
"name": "gemini-recovery/accepts-an-unsigned-settled-tool-call-followed-by-continuation",
|
||||
"recordedAt": "2026-08-22T01:23:18.801Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Look up the weather in Paris.\"}]},{\"role\":\"model\",\"parts\":[{\"text\":\"I should use the weather tool.\"},{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"sunny\"}}}]},{\"role\":\"user\",\"parts\":[{\"text\":\"The previous response was interrupted. Continue from where you left off without repeating completed content.\"}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Return benign weather data for a city.\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}],\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"NONE\"}},\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"The weather in Paris is currently sunny\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 116,\"candidatesTokenCount\": 7,\"totalTokenCount\": 368,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 116}],\"thoughtsTokenCount\": 245,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BfqIaun_Gbau39IP49TwKA\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EuEKCt4KARFNMg+TrcXumi0KeXb7iZG/dBk9QjybBSM9V7hTN2a0lEXMuWh38tNhXrQCwNFDZ2gKEpa2c1lj/+wwzJAvjceCFoRenOGfBEUGRavc6oQXLKiWZPGaHV0G4gIo2uosx8SNZc4E5Dko2IYPymcUzDgsqZK2eToDoepaM8cekix+6c/z2MZiRnJ0QbS3f/7r+UYLOIi7eWbW0G6v+gwPYae4kRuifL6iKEV5eG+R4AGlGfOI0dgamDfEFfGiwg+rkvjKw7DDmYvQVreUcjvFt2OOhKfFWmnvOQrIKkSYpSyvdlylRhKZMU5gwUsKii8xwI5eJVoUoYDoOjr1x8DR3ePkCj/n3b0OMUWunPkdpgE/bUcFMQ+Gzqs7F3sBYDgUiT3F9r5joKZp6dm0I64/gh15J0NCamlr+CRLP+a8tJbEcWZbgELhbFE+2ikJMtoApojvlUbIUsn7sXHQ6042A/MjWgV97DWcG6V1dtLOOrS2Ez2F58TWDW67dTD3o+Ixz82+xDfG4VW0pOiOPOo9n2YCGk9xlSwPG9qDwU6bpi0AFrJVSz2gc8OFXk5uwCooPAN0EmnfljWS5+spS7AmKA2k3S7IZKe0aAw90+Jp4JxklOsfL4XIlQJLLXKmtcnTEdZLYp/JxSL9YRNnoHZJaeBpIWEObE+aX+CuI3OqZZPpldalNeCPM1nCYu26rfBzAaP85zzyi4SFzlXc++E9OPei8BgPoD8XuTb1yK1nTtPqjR8bYvaDm9ef8NaWrYdM+ivVQ/Q/9YAQ8AXuC71dQZExcTQAC5oOnrRP1byo2lHMuytnVk8QrnZBhiJc/TneDFzCYK9nFkhc6s0aaQrBip6YEKPtGmuMmUxJ1dQYFQlgtjEvANB2/jnPtE6u1p039reevLAHMxtijgCqNE0hrVaciI1ZTLTYtO1UquoSXNoHqqZzYuEnLMUKxTbWzIOcVBC4XwP8rkYLwCp9e1oMMBV7FIT6Kv2El7tWrpBt2w+2GgQMt2bAr2PJQf1oj5O/vOXEbcTVumOv29rUJQ65pFpr8K6Q8W6lIbIxOGWLKb/akVl9FJq5lPVp+m3eEYurQcQJ1mB3OEuPrpK4COdiVElarAnaxaMpioa8yCh22kA4eqS+bChUDIZIfCUNRDGI0eO8YMkRo626VLp9IJnexYNBBwBKk5oL7TmrlzRz/YyUEApVNMUjHiRd24i2jq4pQyOdkbYMVi3YFZNQbRuo0EN9dM8vAG4VrAGPj892T2ne5B69Qmhgs8Ow4QrubWd+d6ohdsSyOucVruw1Mr+x79BqiQs+SQ5M/GJCPBYO3QIV3CMGnHNrtvg5YdYE5blCs0MCf+u5mHPPk2TlBuXMJtLUN/YmGhE4u1/ZT9WTIueekSSubPv1eqPwLRHLiwVfKdA/FNqu5YDifgzKMjyIQpXiEIW3OnI4A1ps8K4pMynZ6XXyZuvq74eHnX1vQDdDA27MiWJPrDWqzg5SQnOl026Mcpj0pIex1lN+7av8xAc9PVszdn9qq8WwO7yUOPM10HnE2hY4pDGa3LXo/2VMAbknQOwhEA6vEgGwFVXN2EXYS9fohRgrXbo7hCroJlekcuROXX7sq2JEK751xXWZlaIpujUpNt8uUGRjSIIFKhuYYvrmU7FEN/PgB0eyQ6KQp1iQm7ywzqMF0Ns2XbM6iAlMibLu48G6VplgUWpsNMtbJLLs3QuFeligNdARJ8aMfJarHlRFV/oQXWggnSyTO5wKCOif8IJUWuHR43JStLvFZsOzVvCDduPSdWKs/9vkri3iVbRFCmeTyuJB+xQuz3rbU+iVI9VOe2BIAhKY\"}],\"role\": \"model\"},\"finishReason\": \"MAX_TOKENS\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 116,\"candidatesTokenCount\": 7,\"totalTokenCount\": 368,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 116}],\"thoughtsTokenCount\": 245,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BfqIaun_Gbau39IP49TwKA\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:gemini-recovery",
|
||||
"provider:google",
|
||||
"protocol:gemini",
|
||||
"tool",
|
||||
"parallel",
|
||||
"recovery"
|
||||
],
|
||||
"name": "gemini-recovery/accepts-unsigned-settled-parallel-tools-followed-by-continuation",
|
||||
"recordedAt": "2026-08-22T02:18:17.994Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Look up the weather and local time in Paris.\"}]},{\"role\":\"model\",\"parts\":[{\"text\":\"I should use both lookup tools.\"},{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"},{\"functionCall\":{\"name\":\"lookup_time\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"sunny\"}}},{\"functionResponse\":{\"name\":\"lookup_time\",\"response\":{\"name\":\"lookup_time\",\"content\":\"12:00\"}}}]},{\"role\":\"user\",\"parts\":[{\"text\":\"The previous response was interrupted. Continue from where you left off without repeating completed content.\"}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Return benign weather data for a city.\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}},{\"name\":\"lookup_time\",\"description\":\"Return the benign local time for a city.\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}],\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"NONE\"}},\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"In Paris, the weather is currently sunny, and the local time is 12:00.\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 205,\"candidatesTokenCount\": 21,\"totalTokenCount\": 420,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 205}],\"thoughtsTokenCount\": 194,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"6AaJaoOsJrbI-8YPpqnz0Qg\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"Eq0ICqoIARFNMg8GCNXth8PULrLVoOm0IMGKXbEMpyUD0xr35HHiDNk+Hzd0ZcnORGZeHhr2X4aUfGEBNHLfiPZB58GEozrIKnaij2tY39bV2kwdmauBdATebzhry32Tg0eo8byo1pMdIUQyY8lNoXo5X3zGWjzwiODhG4vOMHAYczSBpj0wuWgQk4GtT9dLi10jNSML4Sjh4ObUSDZYCoLjT7gk8iOcE9imM7O0b6MmHX0oi9gN4n/MF6JZQsmbtHA81zISYq6ADroKmyBdr36pETBj0Z36vRkJYcw+UqMgmTUQ4X0qwCYT+rPd2m6OSgI1mk1ApGr3DKV7ovQGztfbSg1r8mrNIkfbgmptl0vrdWSNonXugIdftdkWA5DvN5HBJlQzutJ9ggYDN8n6Jila1vOwwIPojTKfqOLOrR9gYFSNMe8lVBMuVIsy/ogmVYOkMLb0yUAIQvR5+99ZVmSTiys2GMgf6Q7VYckZIaXXemdHBM/LPXAc6TeYKLODEbjfoyxmmX8VUEnjv1fRJPb9tf7ii7/Js6rsndgQu2UZIC+b/ui8ayCHs+8KCMw4QGijxrdhRM1yo6RRbsEkQxnm0HPyC3I5l1VmZO8WFnn4nBBJ0O6UWBsvy5/KvkgbKj612f6TG7RveK3QNHHGKC2Gd1ztIBOwzB32/x/zvK8X1T950OtiF8a0ExrEI8D9UCbMyFJBDiK8EI5SadKNUTCvdx7P1zwAqjKhOQ8SWF3Fs5xDH1Ea4qRWlUXKzddrj0e9BLDFuUUQ9BSuekbpgTCdm/ArllXKJt2mVrPi1iLFFhDf1rnHSbTamN6aBrT8mjzdu3w5/KI62marHZmHwgQKm89Pw/1ZzZYUjISEAX3SyrMVUW4K4I1goPEjdrY+VcOlRNuz8geZAasM9TkgjxZuHdFQoGMmulLjoBhwFii5jWq4DrC10mfXhGFYS0SNncOj3ZUmwfkEQ5KslsKoNNicHb9TTElKwUcpcAFSLQ2WFG60qd/kXR4EBriIaD5B8cAG/oVjjd0+PRNRrdSV+FMz/N2fZrP+MH7XiH5i+jjhk0/YDgwuAINYrUdLmmMP+IT4MALPhH1SlubyUxE1uUO4+ayutIWMWX5a9zGwSWWK9ehb3cdMUtZwACqlwlCnUIOoVMGcgEFZNcWQv7nhey51U2713L4Ud53s797TeUQF6GG0FssnaBP8ekF0Xo94H4jHUaLmgW2n6OQfOeut/6h1qqRNEocVVFPAKdv0RO3DyC0Ng0rCV98+v6Qp4fK+tIynZobBzoK3h/Bw3/kWAXAgbu4m71/yIe5rQRT9Y3aaJrsJU0TbtSORagcxUYZ4Shpap8RBLGnnN0ozriEsU5ySXyZUK7IwJdO33gfE1bBFhDXITqD8JE32pb+KhaFJ84gjsicsamJBMSVkl+RjPw==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 205,\"candidatesTokenCount\": 21,\"totalTokenCount\": 420,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 205}],\"thoughtsTokenCount\": 194,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"6AaJaoOsJrbI-8YPpqnz0Qg\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:meta-recovery",
|
||||
"provider:meta",
|
||||
"protocol:openai-responses",
|
||||
"tool",
|
||||
"recovery"
|
||||
],
|
||||
"name": "meta-recovery/accepts-a-settled-tool-followed-by-continuation",
|
||||
"recordedAt": "2026-08-22T02:20:24.363Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.meta.ai/v1/responses",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"muse-spark-1.2\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Look up the weather in Paris.\"}]},{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"I should use the weather tool.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_weather\",\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_weather\",\"output\":\"sunny\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"The previous response was interrupted. Continue from where you left off without repeating completed content.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"lookup_weather\",\"description\":\"Return benign weather data for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]},\"strict\":false}],\"store\":false,\"max_output_tokens\":128,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: response.created\ndata: {\"response\":{\"background\":false,\"created_at\":1787365223.0,\"error\":null,\"id\":\"resp_6a890767037bffd1d1154065\",\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":128,\"model\":\"muse-spark-1.2\",\"object\":\"response\",\"output\":[],\"parallel_tool_calls\":true,\"reasoning\":{\"effort\":\"high\"},\"service_tier\":\"auto\",\"status\":\"in_progress\",\"store\":false,\"temperature\":1.0,\"tool_choice\":\"auto\",\"tools\":[{\"description\":\"Return benign weather data for a city.\",\"name\":\"lookup_weather\",\"parameters\":{\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"type\":\"object\"},\"strict\":false,\"type\":\"function\"}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\"},\"sequence_number\":0,\"type\":\"response.created\"}\n\nevent: response.in_progress\ndata: {\"response\":{\"background\":false,\"created_at\":1787365223.0,\"error\":null,\"id\":\"resp_6a890767037bffd1d1154065\",\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":128,\"model\":\"muse-spark-1.2\",\"object\":\"response\",\"output\":[],\"parallel_tool_calls\":true,\"reasoning\":{\"effort\":\"high\"},\"service_tier\":\"auto\",\"status\":\"in_progress\",\"store\":false,\"temperature\":1.0,\"tool_choice\":\"auto\",\"tools\":[{\"description\":\"Return benign weather data for a city.\",\"name\":\"lookup_weather\",\"parameters\":{\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"type\":\"object\"},\"strict\":false,\"type\":\"function\"}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\"},\"sequence_number\":1,\"type\":\"response.in_progress\"}\n\nevent: response.output_item.added\ndata: {\"item\":{\"id\":\"rs_6a890767037bffd1d1154065:rs_01a02744ec457db1bc51d366eb297f28\",\"status\":\"in_progress\",\"summary\":[],\"type\":\"reasoning\"},\"output_index\":0,\"sequence_number\":2,\"type\":\"response.output_item.added\"}\n\nevent: response.incomplete\ndata: {\"response\":{\"background\":false,\"created_at\":1787365223.0,\"error\":null,\"id\":\"resp_6a890767037bffd1d1154065\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"instructions\":null,\"max_output_tokens\":128,\"model\":\"muse-spark-1.2\",\"object\":\"response\",\"output\":[],\"parallel_tool_calls\":true,\"reasoning\":{\"effort\":\"high\"},\"service_tier\":\"auto\",\"status\":\"incomplete\",\"store\":false,\"temperature\":1.0,\"tool_choice\":\"auto\",\"tools\":[{\"description\":\"Return benign weather data for a city.\",\"name\":\"lookup_weather\",\"parameters\":{\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"type\":\"object\"},\"strict\":false,\"type\":\"function\"}],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":646,\"input_tokens_details\":{\"cached_tokens\":177},\"output_tokens\":128,\"output_tokens_details\":{\"reasoning_tokens\":125},\"total_tokens\":774}},\"sequence_number\":3,\"type\":\"response.incomplete\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
packages/ai/test/fixtures/recordings/meta-recovery/rejects-continuation-with-an-unresolved-tool.json
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:meta-recovery",
|
||||
"provider:meta",
|
||||
"protocol:openai-responses",
|
||||
"tool",
|
||||
"recovery",
|
||||
"sad-path"
|
||||
],
|
||||
"name": "meta-recovery/rejects-continuation-with-an-unresolved-tool",
|
||||
"recordedAt": "2026-08-22T02:20:24.441Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.meta.ai/v1/responses",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"muse-spark-1.2\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Look up the weather in Paris.\"}]},{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"I should use the weather tool.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_weather\",\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"The previous response was interrupted. Continue from where you left off without repeating completed content.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"lookup_weather\",\"description\":\"Return benign weather data for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]},\"strict\":false}],\"store\":false,\"max_output_tokens\":32,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 400,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"error\":{\"code\":null,\"message\":\"No tool output found for function call call_weather.\",\"param\":\"input\",\"type\":\"invalid_request_error\"}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+35
File diff suppressed because one or more lines are too long
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:openai-recovery",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"tool",
|
||||
"parallel",
|
||||
"recovery",
|
||||
"sad-path"
|
||||
],
|
||||
"name": "openai-recovery/rejects-continuation-with-a-missing-parallel-tool-result",
|
||||
"recordedAt": "2026-08-22T02:20:25.412Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.openai.com/v1/responses",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Look up the weather and local time in Paris.\"}]},{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"I should use both lookup tools.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_weather\",\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call\",\"call_id\":\"call_time\",\"name\":\"lookup_time\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_weather\",\"output\":\"sunny\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"The previous response was interrupted. Continue from where you left off without repeating completed content.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"lookup_weather\",\"description\":\"Return benign weather data for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]},\"strict\":false},{\"type\":\"function\",\"name\":\"lookup_time\",\"description\":\"Return the benign local time for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]},\"strict\":false}],\"tool_choice\":\"none\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":32,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 400,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\n \"error\": {\n \"message\": \"No tool output found for function call call_time.\",\n \"type\": \"invalid_request_error\",\n \"param\": \"input\",\n \"code\": null\n }\n}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+35
File diff suppressed because one or more lines are too long
@@ -0,0 +1,98 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message, ToolCallPart, ToolDefinition } from "../../src/index.js"
|
||||
import { configure } from "../../src/providers/anthropic.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const model = configure({
|
||||
apiKey: process.env.ZEN_TEST_API_KEY ?? "fixture",
|
||||
baseURL: "https://opencode.ai/zen/v1",
|
||||
}).model("claude-sonnet-4-6")
|
||||
const continuation =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
const lookup = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Return benign weather data for a city.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
})
|
||||
const clock = ToolDefinition.make({
|
||||
name: "lookup_time",
|
||||
description: "Return the benign local time for a city.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
})
|
||||
const recorded = recordedTests({
|
||||
prefix: "anthropic-recovery",
|
||||
provider: "anthropic",
|
||||
protocol: "anthropic-messages",
|
||||
requires: ["ZEN_TEST_API_KEY"],
|
||||
options: { redact: { allowRequestHeaders: ["anthropic-version"] } },
|
||||
})
|
||||
|
||||
describe("Anthropic interrupted recovery recorded", () => {
|
||||
recorded.effect.with(
|
||||
"accepts a synthetic settled tool call followed by continuation",
|
||||
{ tags: ["tool", "recovery"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_anthropic_interrupted_tool_recovery",
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Look up the weather in Paris."),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "I should use the weather tool." },
|
||||
ToolCallPart.make({ id: "call_weather", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_weather", name: "lookup_weather", result: "sunny", resultType: "text" }),
|
||||
Message.user(continuation),
|
||||
],
|
||||
tools: [lookup],
|
||||
toolChoice: "none",
|
||||
generation: { maxTokens: 128, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.trim().length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
"accepts synthetic settled parallel tools followed by continuation",
|
||||
{ tags: ["tool", "parallel", "recovery"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_anthropic_interrupted_parallel_recovery",
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Look up the weather and local time in Paris."),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "I should use both lookup tools." },
|
||||
ToolCallPart.make({ id: "call_weather", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "call_time", name: "lookup_time", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_weather", name: "lookup_weather", result: "sunny", resultType: "text" }),
|
||||
Message.tool({ id: "call_time", name: "lookup_time", result: "12:00", resultType: "text" }),
|
||||
Message.user(continuation),
|
||||
],
|
||||
tools: [lookup, clock],
|
||||
toolChoice: "none",
|
||||
generation: { maxTokens: 128, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.trim().length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -255,7 +255,7 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges parallel tool results into one user message", () =>
|
||||
it.effect("merges parallel tool results and recovery continuation into one user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -269,6 +269,7 @@ describe("Bedrock Converse route", () => {
|
||||
]),
|
||||
Message.tool({ id: "tool_paris", name: "lookup", result: { forecast: "sunny" } }),
|
||||
Message.tool({ id: "tool_london", name: "lookup", result: { forecast: "rainy" } }),
|
||||
Message.user("Continue without repeating completed calls."),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
@@ -300,6 +301,7 @@ describe("Bedrock Converse route", () => {
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
{ text: "Continue without repeating completed calls." },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { AIError, LLM, LLMEvent, Message, ToolCallPart, ToolDefinition } from "../../src/index.js"
|
||||
import { configure } from "../../src/providers/amazon-bedrock.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const model = configure({
|
||||
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
|
||||
region: "us-east-2",
|
||||
}).model("us.anthropic.claude-sonnet-4-6")
|
||||
const continuation =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
const tools = [
|
||||
ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Return benign weather data for a city.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
}),
|
||||
ToolDefinition.make({
|
||||
name: "lookup_time",
|
||||
description: "Return the benign local time for a city.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
}),
|
||||
]
|
||||
const recorded = recordedTests({
|
||||
prefix: "bedrock-recovery",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "bedrock-converse",
|
||||
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
|
||||
})
|
||||
|
||||
const history = (complete: boolean) => [
|
||||
Message.user("Look up the weather and local time in Paris."),
|
||||
Message.assistant([
|
||||
{ type: "text" as const, text: "I should use both lookup tools." },
|
||||
ToolCallPart.make({ id: "call_weather", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "call_time", name: "lookup_time", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_weather", name: "lookup_weather", result: "sunny", resultType: "text" }),
|
||||
...(complete ? [Message.tool({ id: "call_time", name: "lookup_time", result: "12:00", resultType: "text" })] : []),
|
||||
Message.user(continuation),
|
||||
]
|
||||
|
||||
describe("Bedrock Converse interrupted recovery recorded", () => {
|
||||
recorded.effect.with(
|
||||
"accepts settled parallel tools followed by continuation",
|
||||
{ tags: ["tool", "parallel", "recovery", "bearer"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_bedrock_interrupted_parallel_recovery",
|
||||
model,
|
||||
messages: history(true),
|
||||
tools,
|
||||
toolChoice: "none",
|
||||
generation: { maxTokens: 128, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.events.some(LLMEvent.is.finish)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
"rejects continuation with a missing parallel tool result",
|
||||
{ tags: ["tool", "parallel", "recovery", "bearer", "sad-path"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_bedrock_incomplete_parallel_recovery",
|
||||
model,
|
||||
messages: history(false),
|
||||
tools,
|
||||
toolChoice: "none",
|
||||
generation: { maxTokens: 32, temperature: 0 },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message, ToolCallPart, ToolDefinition } from "../../src/index.js"
|
||||
import { configure } from "../../src/providers/google.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const model = configure({
|
||||
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture",
|
||||
}).model("gemini-3.5-flash")
|
||||
const continuation =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
const lookup = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Return benign weather data for a city.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
})
|
||||
const clock = ToolDefinition.make({
|
||||
name: "lookup_time",
|
||||
description: "Return the benign local time for a city.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
})
|
||||
const recorded = recordedTests({
|
||||
prefix: "gemini-recovery",
|
||||
provider: "google",
|
||||
protocol: "gemini",
|
||||
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("Gemini interrupted recovery recorded", () => {
|
||||
recorded.effect.with(
|
||||
"accepts an unsigned settled tool call followed by continuation",
|
||||
{ tags: ["tool", "recovery"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_gemini_interrupted_tool_recovery",
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Look up the weather in Paris."),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "I should use the weather tool." },
|
||||
ToolCallPart.make({ id: "call_weather", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_weather", name: "lookup_weather", result: "sunny", resultType: "text" }),
|
||||
Message.user(continuation),
|
||||
],
|
||||
tools: [lookup],
|
||||
toolChoice: "none",
|
||||
generation: { maxTokens: 256, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.trim().length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
"accepts unsigned settled parallel tools followed by continuation",
|
||||
{ tags: ["tool", "parallel", "recovery"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_gemini_interrupted_parallel_recovery",
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Look up the weather and local time in Paris."),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "I should use both lookup tools." },
|
||||
ToolCallPart.make({ id: "call_weather", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "call_time", name: "lookup_time", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_weather", name: "lookup_weather", result: "sunny", resultType: "text" }),
|
||||
Message.tool({ id: "call_time", name: "lookup_time", result: "12:00", resultType: "text" }),
|
||||
Message.user(continuation),
|
||||
],
|
||||
tools: [lookup, clock],
|
||||
toolChoice: "none",
|
||||
generation: { maxTokens: 256, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.trim().length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { AIError, LLM, LLMEvent, Message, ToolCallPart, ToolDefinition } from "../../src/index.js"
|
||||
import { configure } from "../../src/providers/openai.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const model = configure({
|
||||
apiKey: process.env.META_API_KEY ?? "fixture",
|
||||
baseURL: "https://api.meta.ai/v1",
|
||||
}).responses("muse-spark-1.2")
|
||||
const continuation =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
const lookup = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Return benign weather data for a city.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
})
|
||||
const recorded = recordedTests({
|
||||
prefix: "meta-recovery",
|
||||
provider: "meta",
|
||||
protocol: "openai-responses",
|
||||
requires: ["META_API_KEY"],
|
||||
})
|
||||
|
||||
const history = (settled: boolean) => [
|
||||
Message.user("Look up the weather in Paris."),
|
||||
Message.assistant([
|
||||
{ type: "text" as const, text: "I should use the weather tool." },
|
||||
ToolCallPart.make({ id: "call_weather", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
]),
|
||||
...(settled
|
||||
? [Message.tool({ id: "call_weather", name: "lookup_weather", result: "sunny", resultType: "text" })]
|
||||
: []),
|
||||
Message.user(continuation),
|
||||
]
|
||||
|
||||
describe("Meta Responses interrupted recovery recorded", () => {
|
||||
recorded.effect.with("accepts a settled tool followed by continuation", { tags: ["tool", "recovery"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_meta_interrupted_tool_recovery",
|
||||
model,
|
||||
messages: history(true),
|
||||
tools: [lookup],
|
||||
generation: { maxTokens: 128 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.events.some(LLMEvent.is.finish)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with("rejects continuation with an unresolved tool", { tags: ["tool", "recovery", "sad-path"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_meta_unresolved_tool_recovery",
|
||||
model,
|
||||
messages: history(false),
|
||||
tools: [lookup],
|
||||
generation: { maxTokens: 32 },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.reason.message).toContain("No tool output found")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { AIError, LLM, LLMEvent, Message, ToolCallPart, ToolDefinition } from "../../src/index.js"
|
||||
import { configure } from "../../src/providers/openai.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const model = configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.5")
|
||||
const continuation =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
const tools = [
|
||||
ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Return benign weather data for a city.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
}),
|
||||
ToolDefinition.make({
|
||||
name: "lookup_time",
|
||||
description: "Return the benign local time for a city.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
}),
|
||||
]
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-recovery",
|
||||
provider: "openai",
|
||||
protocol: "openai-responses",
|
||||
requires: ["OPENAI_API_KEY"],
|
||||
})
|
||||
|
||||
const history = (complete: boolean) => [
|
||||
Message.user("Look up the weather and local time in Paris."),
|
||||
Message.assistant([
|
||||
{ type: "text" as const, text: "I should use both lookup tools." },
|
||||
ToolCallPart.make({ id: "call_weather", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "call_time", name: "lookup_time", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_weather", name: "lookup_weather", result: "sunny", resultType: "text" }),
|
||||
...(complete ? [Message.tool({ id: "call_time", name: "lookup_time", result: "12:00", resultType: "text" })] : []),
|
||||
Message.user(continuation),
|
||||
]
|
||||
|
||||
describe("OpenAI Responses interrupted recovery recorded", () => {
|
||||
recorded.effect.with(
|
||||
"accepts settled parallel tools followed by continuation",
|
||||
{ tags: ["tool", "parallel", "recovery"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_openai_interrupted_parallel_recovery",
|
||||
model,
|
||||
messages: history(true),
|
||||
tools,
|
||||
toolChoice: "none",
|
||||
generation: { maxTokens: 128 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.events.some(LLMEvent.is.finish)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
"rejects continuation with a missing parallel tool result",
|
||||
{ tags: ["tool", "parallel", "recovery", "sad-path"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_openai_incomplete_parallel_recovery",
|
||||
model,
|
||||
messages: history(false),
|
||||
tools,
|
||||
toolChoice: "none",
|
||||
generation: { maxTokens: 32 },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.reason.message).toContain("No tool output found")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -2578,6 +2578,11 @@ describe("OpenAI Responses route", () => {
|
||||
const callsAndResults = response.events.filter(
|
||||
(event) => event.type === "tool-call" || event.type === "tool-result",
|
||||
)
|
||||
expect(response.events.find(LLMEvent.is.toolInputStart)).toMatchObject({
|
||||
id: "ws_1",
|
||||
name: "web_search",
|
||||
providerExecuted: true,
|
||||
})
|
||||
expect(callsAndResults).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, Message, ToolCallPart, ToolDefinition } from "../../src/index.js"
|
||||
import { configure } from "../../src/providers/xai.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const model = configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" }).responses("grok-4.6")
|
||||
const continuation =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
const tools = [
|
||||
ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Return benign weather data for a city.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
}),
|
||||
ToolDefinition.make({
|
||||
name: "lookup_time",
|
||||
description: "Return the benign local time for a city.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
},
|
||||
}),
|
||||
]
|
||||
const recorded = recordedTests({
|
||||
prefix: "xai-recovery",
|
||||
provider: "xai",
|
||||
protocol: "openai-responses",
|
||||
requires: ["XAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("xAI Responses interrupted recovery recorded", () => {
|
||||
recorded.effect.with(
|
||||
"accepts settled parallel tools followed by continuation",
|
||||
{ tags: ["tool", "parallel", "recovery"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_xai_interrupted_parallel_recovery",
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Look up the weather and local time in Paris."),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "I should use both lookup tools." },
|
||||
ToolCallPart.make({ id: "call_weather", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "call_time", name: "lookup_time", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_weather", name: "lookup_weather", result: "sunny", resultType: "text" }),
|
||||
Message.tool({ id: "call_time", name: "lookup_time", result: "12:00", resultType: "text" }),
|
||||
Message.user(continuation),
|
||||
],
|
||||
tools,
|
||||
toolChoice: "none",
|
||||
generation: { maxTokens: 128 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.events.some(LLMEvent.is.finish)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -61,11 +61,16 @@ const isDecline = (
|
||||
): error is Permission.DeclinedError | QuestionTool.CancelledError =>
|
||||
error._tag === "Permission.DeclinedError" || error._tag === "QuestionTool.CancelledError"
|
||||
|
||||
const isInterruptedStream = (failure: AIError) => {
|
||||
if (failure.reason._tag === "InvalidProviderOutput")
|
||||
return failure.reason.classification === "incomplete-stream"
|
||||
if (failure.reason._tag === "Transport") return failure.reason.operation === "read"
|
||||
return false
|
||||
const shouldContinueAfterFailure = (failure: AIError, record: StepRecord) => {
|
||||
const interrupted =
|
||||
(failure.reason._tag === "InvalidProviderOutput" && failure.reason.classification === "incomplete-stream") ||
|
||||
(failure.reason._tag === "Transport" && failure.reason.operation === "read")
|
||||
const providerRetry =
|
||||
(failure.reason._tag === "RateLimit" || failure.reason._tag === "ProviderInternal") &&
|
||||
SessionRunnerRetry.isRetryable(failure)
|
||||
// Settlement gives eager local executions durable outcomes. Hosted activity remains
|
||||
// provider-side and is not uniformly replayable, so it cannot cross this boundary.
|
||||
return (interrupted || providerRetry) && record.calls.every((call) => call.settled && !call.providerExecuted)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,7 +125,7 @@ const classifyToolExits = (
|
||||
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
|
||||
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
|
||||
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
|
||||
const CONTINUE_AFTER_INCOMPLETE_STREAM =
|
||||
const CONTINUE_AFTER_PARTIAL_FAILURE =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
|
||||
const layer = Layer.effect(
|
||||
@@ -302,7 +307,7 @@ const layer = Layer.effect(
|
||||
).pipe(Pull.catchDone(() => Effect.fail(outcome.cause)))
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_INCOMPLETE_STREAM,
|
||||
text: CONTINUE_AFTER_PARTIAL_FAILURE,
|
||||
})
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
}
|
||||
@@ -597,7 +602,7 @@ const layer = Layer.effect(
|
||||
if (
|
||||
llmFailure &&
|
||||
llmError &&
|
||||
isInterruptedStream(llmFailure) &&
|
||||
shouldContinueAfterFailure(llmFailure, record) &&
|
||||
record.outputStarted &&
|
||||
tools.declines.length === 0 &&
|
||||
!tools.interrupted
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLM } from "@opencode-ai/ai"
|
||||
import { configure } from "@opencode-ai/ai/providers/anthropic"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { DateTime, Effect } from "effect"
|
||||
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const anthropic = Model.Ref.make({
|
||||
id: Model.ID.make("claude-sonnet-4-6"),
|
||||
providerID: Provider.ID.make("anthropic"),
|
||||
})
|
||||
const continuation =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
|
||||
test("failed Anthropic tool history replays through the public request boundary", async () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.make("msg_user"),
|
||||
type: "user",
|
||||
text: "Look up the weather.",
|
||||
time: { created },
|
||||
}),
|
||||
SessionMessage.Assistant.make({
|
||||
id: SessionMessage.ID.make("msg_failed"),
|
||||
type: "assistant",
|
||||
agent: Agent.defaultID,
|
||||
model: anthropic,
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
text: "I should use the weather tool.",
|
||||
state: { signature: "signed-reasoning" },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "call_weather",
|
||||
name: "lookup_weather",
|
||||
executed: false,
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { city: "Paris" },
|
||||
content: [{ type: "text", text: "sunny" }],
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
finish: "error",
|
||||
error: { type: "provider.internal", message: "Provider stream failed" },
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
SessionMessage.Synthetic.make({
|
||||
id: SessionMessage.ID.make("msg_continue"),
|
||||
type: "synthetic",
|
||||
text: continuation,
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
anthropic,
|
||||
)
|
||||
const prepared = await Effect.runPromise(
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model: configure({ apiKey: "test" }).model("claude-sonnet-4-6"),
|
||||
messages,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(messages).toMatchObject([
|
||||
{ role: "user" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I should use the weather tool." },
|
||||
{ type: "tool-call", id: "call_weather", providerMetadata: undefined },
|
||||
],
|
||||
},
|
||||
{ role: "tool", content: [{ type: "tool-result", id: "call_weather" }] },
|
||||
{ role: "user", content: [{ type: "text", text: continuation }] },
|
||||
])
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "user", content: [{ type: "text", text: "Look up the weather." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I should use the weather tool." },
|
||||
{ type: "tool_use", id: "call_weather", name: "lookup_weather", input: { city: "Paris" } },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_weather", content: "sunny" }] },
|
||||
{ role: "user", content: [{ type: "text", text: continuation }] },
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLM, Message } from "@opencode-ai/ai"
|
||||
import { configure } from "@opencode-ai/ai/providers/google"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { DateTime, Effect } from "effect"
|
||||
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const google = Model.Ref.make({
|
||||
id: Model.ID.make("gemini-3.5-flash"),
|
||||
providerID: Provider.ID.make("google"),
|
||||
})
|
||||
const continuation =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
|
||||
test("failed Gemini tool history replays through the public request boundary", async () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.make("msg_user"),
|
||||
type: "user",
|
||||
text: "Look up the weather.",
|
||||
time: { created },
|
||||
}),
|
||||
SessionMessage.Assistant.make({
|
||||
id: SessionMessage.ID.make("msg_failed"),
|
||||
type: "assistant",
|
||||
agent: Agent.defaultID,
|
||||
model: google,
|
||||
content: [
|
||||
SessionMessage.AssistantReasoning.make({
|
||||
type: "reasoning",
|
||||
text: "I should use the weather tool.",
|
||||
state: { thoughtSignature: "signed-reasoning" },
|
||||
}),
|
||||
SessionMessage.AssistantTool.make({
|
||||
type: "tool",
|
||||
id: "call_weather",
|
||||
name: "lookup_weather",
|
||||
executed: false,
|
||||
providerState: { thoughtSignature: "signed-tool-call" },
|
||||
state: SessionMessage.ToolStateCompleted.make({
|
||||
status: "completed",
|
||||
input: { city: "Paris" },
|
||||
content: [{ type: "text", text: "sunny" }],
|
||||
}),
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
finish: "error",
|
||||
error: { type: "provider.internal", message: "Provider stream failed" },
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
SessionMessage.Synthetic.make({
|
||||
id: SessionMessage.ID.make("msg_continue"),
|
||||
type: "synthetic",
|
||||
text: continuation,
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
google,
|
||||
)
|
||||
const prepared = await Effect.runPromise(
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model: configure({ apiKey: "test" }).model("gemini-3.5-flash"),
|
||||
messages,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(messages).toMatchObject([
|
||||
{ role: "user" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I should use the weather tool." },
|
||||
{ type: "tool-call", id: "call_weather", providerMetadata: undefined },
|
||||
],
|
||||
},
|
||||
{ role: "tool", content: [{ type: "tool-result", id: "call_weather" }] },
|
||||
{ role: "user", content: [{ type: "text", text: continuation }] },
|
||||
])
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{ role: "user", parts: [{ text: "Look up the weather." }] },
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "I should use the weather tool.", thoughtSignature: undefined },
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup_weather", args: { city: "Paris" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup_weather",
|
||||
response: { name: "lookup_weather", content: "sunny" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", parts: [{ text: continuation }] },
|
||||
])
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
ProviderInternalReason,
|
||||
RateLimitReason,
|
||||
} from "@opencode-ai/ai"
|
||||
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
|
||||
@@ -572,6 +573,13 @@ const streamDisconnected = () =>
|
||||
}),
|
||||
})
|
||||
|
||||
const providerInternal = () =>
|
||||
new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new ProviderInternalReason({ message: "The provider failed while generating a response" }),
|
||||
})
|
||||
|
||||
const continuationRejected = (recovery: "retry-full" | "rotate-and-retry-full") =>
|
||||
new AIError({
|
||||
module: "test",
|
||||
@@ -596,7 +604,7 @@ const incompleteStream = () =>
|
||||
}),
|
||||
})
|
||||
|
||||
const INCOMPLETE_STREAM_CONTINUATION =
|
||||
const PARTIAL_FAILURE_CONTINUATION =
|
||||
"The previous response was interrupted. Continue from where you left off without repeating completed content."
|
||||
|
||||
const invalidRequest = () =>
|
||||
@@ -4591,7 +4599,7 @@ describe("SessionRunnerLLM", () => {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: INCOMPLETE_STREAM_CONTINUATION,
|
||||
text: PARTIAL_FAILURE_CONTINUATION,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -4606,7 +4614,7 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
{
|
||||
type: "synthetic",
|
||||
text: INCOMPLETE_STREAM_CONTINUATION,
|
||||
text: PARTIAL_FAILURE_CONTINUATION,
|
||||
},
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: " continuation" }] },
|
||||
])
|
||||
@@ -4641,12 +4649,12 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.messages.at(-1)).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
|
||||
content: [{ type: "text", text: PARTIAL_FAILURE_CONTINUATION }],
|
||||
})
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{ type: "assistant", finish: "error", content: [{ type: "text", text: "Partial" }] },
|
||||
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
|
||||
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: " continuation" }] },
|
||||
])
|
||||
}),
|
||||
@@ -4680,7 +4688,7 @@ describe("SessionRunnerLLM", () => {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: INCOMPLETE_STREAM_CONTINUATION,
|
||||
text: PARTIAL_FAILURE_CONTINUATION,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -4720,7 +4728,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
|
||||
expect(requests[1]?.messages.slice(-2)).toMatchObject([
|
||||
{ role: "user", content: [{ type: "text", text: "Recover disconnected reasoning" }] },
|
||||
{ role: "user", content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }] },
|
||||
{ role: "user", content: [{ type: "text", text: PARTIAL_FAILURE_CONTINUATION }] },
|
||||
])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
@@ -4735,12 +4743,139 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
|
||||
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a provider failure after reasoning without tool activity", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Recover failed reasoning")
|
||||
const failure = providerInternal()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.reasoningStart({ id: "failed-reasoning" }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "failed-reasoning",
|
||||
providerMetadata: {
|
||||
openai: { itemId: "rs_failed", reasoningEncryptedContent: "failed-encrypted-state" },
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* TestLLM.push(TestLLM.text("Recovered", "provider-failure-recovery"))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("2400 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1]?.messages).toHaveLength(2)
|
||||
expect(requests[1]?.messages.slice(-2)).toMatchObject([
|
||||
{ role: "user", content: [{ type: "text", text: "Recover failed reasoning" }] },
|
||||
{ role: "user", content: [{ type: "text", text: PARTIAL_FAILURE_CONTINUATION }] },
|
||||
])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "provider.internal" },
|
||||
content: [{ type: "reasoning", text: "" }],
|
||||
},
|
||||
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a provider failure after a settled local tool without repeating it", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Continue without repeating tool activity")
|
||||
const failure = providerInternal()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-before-provider-failure", name: "echo", input: { text: "settled" } }),
|
||||
),
|
||||
)
|
||||
yield* TestLLM.push(TestLLM.text("Recovered", "provider-failure-tool-recovery"))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (!(yield* recordedEventTypes(sessionID)).includes("session.retry.scheduled.1")) yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("2400 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(executions).toEqual(["settled"])
|
||||
expect(requests[1]?.messages.slice(-3)).toMatchObject([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool-call", id: "call-before-provider-failure", name: "echo" }],
|
||||
},
|
||||
{ role: "tool", content: [{ type: "tool-result", id: "call-before-provider-failure" }] },
|
||||
{ role: "user", content: [{ type: "text", text: PARTIAL_FAILURE_CONTINUATION }] },
|
||||
])
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not continue a provider failure after hosted tool activity", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const failure = providerInternal()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "hosted-before-provider-failure", name: "web_search", providerExecuted: true }),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* runPrompt(session, "Do not repeat hosted activity").pipe(Effect.flip)).toBe(failure)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a retryable rate limit after partial output", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Recover after rate limit")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.failAfter(
|
||||
rateLimited(),
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "partial-rate-limit" }),
|
||||
LLMEvent.textDelta({ id: "partial-rate-limit", text: "Partial" }),
|
||||
),
|
||||
)
|
||||
yield* TestLLM.push(TestLLM.text(" continuation", "rate-limit-recovery"))
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("2400 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{ type: "assistant", finish: "error", content: [{ type: "text", text: "Partial" }] },
|
||||
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: " continuation" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues an incomplete stream after settling a local tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -4772,7 +4907,7 @@ describe("SessionRunnerLLM", () => {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: INCOMPLETE_STREAM_CONTINUATION,
|
||||
text: PARTIAL_FAILURE_CONTINUATION,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -4812,7 +4947,7 @@ describe("SessionRunnerLLM", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
|
||||
{ type: "synthetic", text: PARTIAL_FAILURE_CONTINUATION },
|
||||
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
|
||||
])
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user