Compare commits

...

6 Commits

Author SHA1 Message Date
kitlangton 24174698ed test(ai): record provider recovery matrix 2026-08-22 02:23:31 +00:00
kitlangton 361a570802 test(ai): cover parallel recovery replay 2026-08-22 02:18:25 +00:00
kitlangton d44b793a91 test(core): cover Anthropic partial recovery 2026-08-22 01:52:57 +00:00
kitlangton aa2e22b62d test(core): cover Gemini partial recovery 2026-08-22 01:24:44 +00:00
Kit Langton 1b0435e464 fix(core): recover settled local tool failures 2026-08-21 19:51:27 -04:00
Kit Langton 28488a4e35 fix(core): recover partial provider failures 2026-08-21 19:51:26 -04:00
24 changed files with 1310 additions and 19 deletions
@@ -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)
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -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\"}"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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"
}
}
]
}
@@ -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\"}}"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -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}"
}
}
]
}
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)
}),
)
})
+13 -8
View File
@@ -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 }] },
])
})
+145 -10
View File
@@ -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" }] },
])
}),