From 052c30eac3d2169d739b06bd6da1444aca245b12 Mon Sep 17 00:00:00 2001 From: oleg Date: Wed, 26 Aug 2026 07:22:13 +0000 Subject: [PATCH] fix(core): Keep abandoned tool-call suspensions visible in AI Assistant thread history (#36737) --- ...s-not-break-provider-message-ordering.json | 85 ++++++--- ...ng-of-messages-in-memory-after-resume.json | 85 ++++++--- ...s-not-break-provider-message-ordering.json | 85 ++++++--- .../runtime/__tests__/message-list.test.ts | 34 ++++ .../strip-orphaned-tool-messages.test.ts | 172 +++++++++++++++++- .../agents/src/runtime/loop/agent-runtime.ts | 15 ++ .../src/runtime/memory/memory-orchestrator.ts | 10 +- .../memory/strip-orphaned-tool-messages.ts | 53 +++++- .../agents/src/runtime/model/message-list.ts | 21 ++- packages/@n8n/agents/src/types/sdk/message.ts | 14 +- .../instance-ai/src/agent/system-prompt.ts | 3 +- .../tools/workflows/build-workflow.tool.ts | 5 +- 12 files changed, 484 insertions(+), 98 deletions(-) diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/interim-user-message-during-suspend/generate-interim-message-does-not-break-provider-message-ordering.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/interim-user-message-during-suspend/generate-interim-message-does-not-break-provider-message-ordering.json index 85188362d76..90d9a7bc2a7 100644 --- a/packages/@n8n/agents/src/__tests__/integration/cassettes/interim-user-message-during-suspend/generate-interim-message-does-not-break-provider-message-ordering.json +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/interim-user-message-during-suspend/generate-interim-message-does-not-break-provider-message-ordering.json @@ -46,30 +46,30 @@ } }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01EnqFT8EPmZWjHrWYgoh19p\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01Uro8PcgjCZT5hFmxDqmt29\",\"name\":\"delete_file\",\"input\":{\"path\":\"/tmp/interim-test.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":629,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":61,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeE6ytibZSyMKdtSdxJuT\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01CGnHG2Mgi14kX9YptML9tR\",\"name\":\"delete_file\",\"input\":{\"path\":\"/tmp/interim-test.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":629,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":61,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "10000000", - "anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:07Z", + "anthropic-ratelimit-input-tokens-reset": "2026-08-20T13:47:07Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:07Z", + "anthropic-ratelimit-output-tokens-reset": "2026-08-20T13:47:07Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-07-03T09:47:06Z", + "anthropic-ratelimit-requests-reset": "2026-08-20T13:47:06Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "12000000", - "anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:07Z", + "anthropic-ratelimit-tokens-reset": "2026-08-20T13:47:07Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a154f7852daa2f95-VIE", + "cf-ray": "a2e1d9163d4abb94-PRG", "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Fri, 03 Jul 2026 09:47:07 GMT", - "request-id": "req_011CceurR4NhauetyZxvhpup", + "date": "Thu, 20 Aug 2026 13:47:07 GMT", + "request-id": "req_011CeE6ys5cygHXyVLtaV79K", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-1d7bd32e330bf09b94f07aac753ddd62-50c7378e7bb49636-01", + "traceresponse": "00-85861d820196f1f3917c4b11c6f381a4-49a1f4935e264fbf-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, @@ -95,6 +95,33 @@ { "type": "text", "text": "Please delete /tmp/interim-test.txt" + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01CGnHG2Mgi14kX9YptML9tR", + "name": "delete_file", + "input": { + "path": "/tmp/interim-test.txt" + }, + "caller": { + "type": "direct" + } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01CGnHG2Mgi14kX9YptML9tR", + "content": "INTERRUPTED — this delete_file call never completed. The user was shown a confirmation (\"Delete \"/tmp/interim-test.txt\"?\") and never answered it. Its action did NOT take effect: nothing was saved, executed, or applied by this call. Do not describe this action as done. If it is still needed, explain to the user that it requires their approval and what approving will do, then re-issue it once.", + "is_error": true }, { "type": "text", @@ -105,30 +132,30 @@ ] }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01MmT2fvAKbKoS36sivZ87nv\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I can't delete files from your system. I'm an AI assistant without access to your file system or the ability to execute commands on your computer.\\n\\nTo delete the file yourself, you can use:\\n- **Linux/Mac**: `rm /tmp/interim-test.txt`\\n- **Windows**: `del C:\\\\temp\\\\interim-test.txt`\\n\\nTo answer your math question: **1 + 1 = 2**\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":36,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":97,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeE6yyAF9B9x9tGAFpKFn\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"1 + 1 = 2\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":212,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":13,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "10000000", - "anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:07Z", + "anthropic-ratelimit-input-tokens-reset": "2026-08-20T13:47:08Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:08Z", + "anthropic-ratelimit-output-tokens-reset": "2026-08-20T13:47:08Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-07-03T09:47:07Z", + "anthropic-ratelimit-requests-reset": "2026-08-20T13:47:08Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "12000000", - "anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:07Z", + "anthropic-ratelimit-tokens-reset": "2026-08-20T13:47:08Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a154f78ba8a42f95-VIE", + "cf-ray": "a2e1d91e6b8bbb94-PRG", "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Fri, 03 Jul 2026 09:47:08 GMT", - "request-id": "req_011CceurVNq5VeCS4aeUBgTx", + "date": "Thu, 20 Aug 2026 13:47:08 GMT", + "request-id": "req_011CeE6yxeEvTbEzMdSpFE9f", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-e264c50d366748e6df27a951c442956d-2dd42bce2c8f7ba3-01", + "traceresponse": "00-e8c9ac1407f70a985d587bd485676792-7e97abd826c26818-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, @@ -162,7 +189,7 @@ "content": [ { "type": "tool_use", - "id": "toolu_01Uro8PcgjCZT5hFmxDqmt29", + "id": "toolu_01CGnHG2Mgi14kX9YptML9tR", "name": "delete_file", "input": { "path": "/tmp/interim-test.txt" @@ -178,7 +205,7 @@ "content": [ { "type": "tool_result", - "tool_use_id": "toolu_01Uro8PcgjCZT5hFmxDqmt29", + "tool_use_id": "toolu_01CGnHG2Mgi14kX9YptML9tR", "content": "{\"deleted\":true,\"path\":\"/tmp/interim-test.txt\"}" } ] @@ -207,30 +234,30 @@ } }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_017piTttFSVzw2R5YDHk4FRj\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Done. The file `/tmp/interim-test.txt` has been deleted.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":718,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":20,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeE6z1m1dLDrpwKeW41Wo\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The file `/tmp/interim-test.txt` has been successfully deleted.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":718,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":19,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "10000000", - "anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:09Z", + "anthropic-ratelimit-input-tokens-reset": "2026-08-20T13:47:09Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:09Z", + "anthropic-ratelimit-output-tokens-reset": "2026-08-20T13:47:10Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-07-03T09:47:08Z", + "anthropic-ratelimit-requests-reset": "2026-08-20T13:47:08Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "12000000", - "anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:09Z", + "anthropic-ratelimit-tokens-reset": "2026-08-20T13:47:09Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a154f793b86e2f95-VIE", + "cf-ray": "a2e1d9226f0cbb94-PRG", "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Fri, 03 Jul 2026 09:47:09 GMT", - "request-id": "req_011Cceuratie1LZarVJwEhi9", + "date": "Thu, 20 Aug 2026 13:47:10 GMT", + "request-id": "req_011CeE6z1MTSYiWHpmf41RJY", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-c6b192b6cc3982fdcd2b61a74f332396-9270fc23470f0b1a-01", + "traceresponse": "00-b4ee5f81bdcd05ebd86bf058ee408dc2-6c0f7158ee2700ed-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/interim-user-message-during-suspend/preserves-chronological-ordering-of-messages-in-memory-after-resume.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/interim-user-message-during-suspend/preserves-chronological-ordering-of-messages-in-memory-after-resume.json index 0e1fea61b21..a0a0376b27a 100644 --- a/packages/@n8n/agents/src/__tests__/integration/cassettes/interim-user-message-during-suspend/preserves-chronological-ordering-of-messages-in-memory-after-resume.json +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/interim-user-message-during-suspend/preserves-chronological-ordering-of-messages-in-memory-after-resume.json @@ -46,30 +46,30 @@ } }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01PAKgMWTmRgjHRK3ak6LCtQ\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01GQeR8FTAmiKSVej51UTLjH\",\"name\":\"delete_file\",\"input\":{\"path\":\"/tmp/order-test.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":628,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":61,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeE6zLuwds7KqLD8WmGWT\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_019mUUAatkj38qZAt6KzskKE\",\"name\":\"delete_file\",\"input\":{\"path\":\"/tmp/order-test.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":628,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":61,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "10000000", - "anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:13Z", + "anthropic-ratelimit-input-tokens-reset": "2026-08-20T13:47:13Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:13Z", + "anthropic-ratelimit-output-tokens-reset": "2026-08-20T13:47:13Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-07-03T09:47:12Z", + "anthropic-ratelimit-requests-reset": "2026-08-20T13:47:13Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "12000000", - "anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:13Z", + "anthropic-ratelimit-tokens-reset": "2026-08-20T13:47:13Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a154f7ab59ab2f95-VIE", + "cf-ray": "a2e1d93e4cc9bb94-PRG", "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Fri, 03 Jul 2026 09:47:13 GMT", - "request-id": "req_011Cceurs6J3Hi618SWaanik", + "date": "Thu, 20 Aug 2026 13:47:13 GMT", + "request-id": "req_011CeE6zLUQECSHDZ4Pamd2R", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-1018d5a7613260b4899eada070e88d00-9b4ca326b046fbf2-01", + "traceresponse": "00-f9239d3b2a5b10bb36be146c4ba8ec6f-7e26229d67ad8f88-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, @@ -95,6 +95,33 @@ { "type": "text", "text": "Delete /tmp/order-test.txt" + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_019mUUAatkj38qZAt6KzskKE", + "name": "delete_file", + "input": { + "path": "/tmp/order-test.txt" + }, + "caller": { + "type": "direct" + } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_019mUUAatkj38qZAt6KzskKE", + "content": "INTERRUPTED — this delete_file call never completed. The user was shown a confirmation (\"Delete \"/tmp/order-test.txt\"?\") and never answered it. Its action did NOT take effect: nothing was saved, executed, or applied by this call. Do not describe this action as done. If it is still needed, explain to the user that it requires their approval and what approving will do, then re-issue it once.", + "is_error": true }, { "type": "text", @@ -105,30 +132,30 @@ ] }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01YLL8CSJZBgYsLkXQSWNKCQ\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I can't actually execute commands on your system. I can't delete files or access your filesystem.\\n\\nIf you want to delete that file, you can run:\\n\\n```bash\\nrm /tmp/order-test.txt\\n```\\n\\nHi! 👋\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":24,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":57,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeE6zQCtBM8T7tSEL7SGg\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Hi! 👋\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":199,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":8,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "10000000", - "anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:13Z", + "anthropic-ratelimit-input-tokens-reset": "2026-08-20T13:47:14Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:14Z", + "anthropic-ratelimit-output-tokens-reset": "2026-08-20T13:47:14Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-07-03T09:47:13Z", + "anthropic-ratelimit-requests-reset": "2026-08-20T13:47:13Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "12000000", - "anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:13Z", + "anthropic-ratelimit-tokens-reset": "2026-08-20T13:47:14Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a154f7b0d8e02f95-VIE", + "cf-ray": "a2e1d94319d5bb94-PRG", "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Fri, 03 Jul 2026 09:47:14 GMT", - "request-id": "req_011Cceurvo3NtBmtL4dwVT1t", + "date": "Thu, 20 Aug 2026 13:47:14 GMT", + "request-id": "req_011CeE6zPjrNAdVmZHDna22u", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-1c4015f4ddb3ee68df66fdba1a2bd642-43f23952779c8045-01", + "traceresponse": "00-539542eb755e977ee5f3e3a6da757e6e-de12a4df504895cd-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, @@ -162,7 +189,7 @@ "content": [ { "type": "tool_use", - "id": "toolu_01GQeR8FTAmiKSVej51UTLjH", + "id": "toolu_019mUUAatkj38qZAt6KzskKE", "name": "delete_file", "input": { "path": "/tmp/order-test.txt" @@ -178,7 +205,7 @@ "content": [ { "type": "tool_result", - "tool_use_id": "toolu_01GQeR8FTAmiKSVej51UTLjH", + "tool_use_id": "toolu_019mUUAatkj38qZAt6KzskKE", "content": "{\"deleted\":true,\"path\":\"/tmp/order-test.txt\"}" } ] @@ -207,30 +234,30 @@ } }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01SyCT4wuK36YHQLeCSbXD14\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Done. The file `/tmp/order-test.txt` has been deleted.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":717,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":20,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeE6zTx7CB4A1dFQvfce9\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"File deleted successfully.\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":717,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":7,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "10000000", - "anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:15Z", + "anthropic-ratelimit-input-tokens-reset": "2026-08-20T13:47:15Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:15Z", + "anthropic-ratelimit-output-tokens-reset": "2026-08-20T13:47:15Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-07-03T09:47:14Z", + "anthropic-ratelimit-requests-reset": "2026-08-20T13:47:14Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "12000000", - "anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:15Z", + "anthropic-ratelimit-tokens-reset": "2026-08-20T13:47:15Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a154f7b84db52f95-VIE", + "cf-ray": "a2e1d947addcbb94-PRG", "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Fri, 03 Jul 2026 09:47:15 GMT", - "request-id": "req_011Cceus2159kBBXhfqdCWx3", + "date": "Thu, 20 Aug 2026 13:47:15 GMT", + "request-id": "req_011CeE6zSxaN8jj2pVZkyeJv", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-40fc288030ad26dc01a3b2458bb3663d-2b1b2075b53226c8-01", + "traceresponse": "00-d79cf3755cbadf040132eb98d80b3632-71c7d3b5bbb39b4f-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, diff --git a/packages/@n8n/agents/src/__tests__/integration/cassettes/interim-user-message-during-suspend/stream-interim-message-does-not-break-provider-message-ordering.json b/packages/@n8n/agents/src/__tests__/integration/cassettes/interim-user-message-during-suspend/stream-interim-message-does-not-break-provider-message-ordering.json index 21d09a69391..371e89ac7ef 100644 --- a/packages/@n8n/agents/src/__tests__/integration/cassettes/interim-user-message-during-suspend/stream-interim-message-does-not-break-provider-message-ordering.json +++ b/packages/@n8n/agents/src/__tests__/integration/cassettes/interim-user-message-during-suspend/stream-interim-message-does-not-break-provider-message-ordering.json @@ -46,30 +46,30 @@ } }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01Q4ikLq8n4cbc4ZgHqszwz1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_017RwPnNVuN8WkzFkDUp1Ruu\",\"name\":\"delete_file\",\"input\":{\"path\":\"/tmp/interim-test.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":629,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":61,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeE6z8ovT2Z3MCC8dDWF5\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01LC4A1xXP3auktG1rpfcuk9\",\"name\":\"delete_file\",\"input\":{\"path\":\"/tmp/interim-test.txt\"},\"caller\":{\"type\":\"direct\"}}],\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":629,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":61,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "10000000", - "anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:10Z", + "anthropic-ratelimit-input-tokens-reset": "2026-08-20T13:47:11Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:10Z", + "anthropic-ratelimit-output-tokens-reset": "2026-08-20T13:47:11Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-07-03T09:47:09Z", + "anthropic-ratelimit-requests-reset": "2026-08-20T13:47:10Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "12000000", - "anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:10Z", + "anthropic-ratelimit-tokens-reset": "2026-08-20T13:47:11Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a154f7987d002f95-VIE", + "cf-ray": "a2e1d92c6ef3bb94-PRG", "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Fri, 03 Jul 2026 09:47:10 GMT", - "request-id": "req_011Cceure9g2zGrzVb1Z6z32", + "date": "Thu, 20 Aug 2026 13:47:11 GMT", + "request-id": "req_011CeE6z8CThGAD2RxASg9c3", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-8aa902ee1e7eb6f58b5e2878d75a119a-635b89f5d10d5738-01", + "traceresponse": "00-b6e65748653ea3f1605720b9cfe7a338-cca2c7b55c120740-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, @@ -95,6 +95,33 @@ { "type": "text", "text": "Please delete /tmp/interim-test.txt" + } + ] + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01LC4A1xXP3auktG1rpfcuk9", + "name": "delete_file", + "input": { + "path": "/tmp/interim-test.txt" + }, + "caller": { + "type": "direct" + } + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01LC4A1xXP3auktG1rpfcuk9", + "content": "INTERRUPTED — this delete_file call never completed. The user was shown a confirmation (\"Delete \"/tmp/interim-test.txt\"?\") and never answered it. Its action did NOT take effect: nothing was saved, executed, or applied by this call. Do not describe this action as done. If it is still needed, explain to the user that it requires their approval and what approving will do, then re-issue it once.", + "is_error": true }, { "type": "text", @@ -105,30 +132,30 @@ ] }, "status": 200, - "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_012B2ryS5wEuCVHWhmdP76TG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I can't delete files. I'm an AI assistant without access to your file system.\\n\\nHowever, I can answer your math question: **1 + 1 = 2**\\n\\nIf you need to delete that file, you can do it yourself using:\\n- Command line: `rm /tmp/interim-test.txt`\\n- File manager: Navigate to `/tmp/` and delete it manually\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":36,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":89,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", + "response": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CeE6zD2g1JHbkoCqf5gzi\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"1 + 1 = 2\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":212,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":13,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}", "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "10000000", - "anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:10Z", + "anthropic-ratelimit-input-tokens-reset": "2026-08-20T13:47:11Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:11Z", + "anthropic-ratelimit-output-tokens-reset": "2026-08-20T13:47:11Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-07-03T09:47:10Z", + "anthropic-ratelimit-requests-reset": "2026-08-20T13:47:11Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "12000000", - "anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:10Z", + "anthropic-ratelimit-tokens-reset": "2026-08-20T13:47:11Z", "cf-cache-status": "DYNAMIC", - "cf-ray": "a154f79d4b0c2f95-VIE", + "cf-ray": "a2e1d932ac29bb94-PRG", "connection": "keep-alive", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "application/json", - "date": "Fri, 03 Jul 2026 09:47:11 GMT", - "request-id": "req_011CceurhQdb8mFHcFMpoT65", + "date": "Thu, 20 Aug 2026 13:47:11 GMT", + "request-id": "req_011CeE6zCYQJhwwxZ1XgEct2", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-db38b2887ddda7cad3d7fd296fc30f9f-daacc71bf36111e6-01", + "traceresponse": "00-66d0cb732117d7b6d3001270957fc73e-cc90527a425940cc-01", "vary": "Accept-Encoding", "x-robots-tag": "none" }, @@ -162,7 +189,7 @@ "content": [ { "type": "tool_use", - "id": "toolu_017RwPnNVuN8WkzFkDUp1Ruu", + "id": "toolu_01LC4A1xXP3auktG1rpfcuk9", "name": "delete_file", "input": { "path": "/tmp/interim-test.txt" @@ -178,7 +205,7 @@ "content": [ { "type": "tool_result", - "tool_use_id": "toolu_017RwPnNVuN8WkzFkDUp1Ruu", + "tool_use_id": "toolu_01LC4A1xXP3auktG1rpfcuk9", "content": "{\"deleted\":true,\"path\":\"/tmp/interim-test.txt\"}" } ] @@ -209,33 +236,33 @@ }, "status": 200, "response": [ - "1f8b0800000000000013bd54c18ed33014bcf72bac774ebb4dd50ac80d690f5c910a1220e475e36963d5b183fd52ba5de5df51ba099b9416b8402e51669e3df3e6e9050738ce448918d50e32b20a3cd18a55269e881f2b5046239292fe9bb2272abd86a58c72ab6a8d69a1ccbe9e2ea7abe962be58a5f3794a0919ddde1177729ebef97ec269f3eec33dc78f6f4febf7cb75f8f47949c985122514bc6d0115a389ac5c2b9b7bc7704cd997af0945f6950c50d13bca5c6d6d07457cabe1728c400d56c6c61eab7bf7c655354bf67bb848d9abf47542b9ca0bc83c40b1f14e8e2be63d1fa0f42dae3fdb0aa02a5022282b57e5aff52f6c5a5cb24d42bee621942614110e268764834019b5c16815749bb1db22b47dcb1d3c65e43c4b7550c6aa8d05358d183ccd6482e7a97789ca8df5f9fefaecaf949ce5348ecf3d0ff9b6e5ee1ce3d8169e5f19d1c8c0c04165dcee4252500bd22d971a96d5ef5d9e4b462e9f91b1bb9f659dc77befd0f9fcefda33b12e20b6c6423cdc7159dd19c708a69c3222cff8c80fa250516c0027342c185ac43acf11e3b6b6f671f612f0ede9faea4fc3f5d5c0b9b831b2fe6f70358c114983e647fb4a705a721d1cfdf5d636ff6e6d2ff66c9136d7dabd96df90a32ea51f3d23137a51050000" + "1f8b0800000000000013b553b16edb3010ddfd15c4cd5262a5759a726b8ba24b870e4551a02898b378b65853a4429e0ca781febda06dc5962b2759a285d07b8f7cefee70b426c752d414232e4945c6c0138d8c523c00df370412062464fd3fc807a8bd260b124a8bada6bc42b36af3b7f92cbf9a5ecd8ae9b4800c8c4e6fc4a59a16c527fa7cfdf7cbddcd879f1fe77ffc8fafdfdc0aaf213b71820c82b709c0184d6474c9b6f48ec931c85fbf3388ec1b1508a377205d6bed1e8a74d7922b69006a623436f658dba737ae6959b15f918b20df153719945856a4ca40c8c63b35544c7b3e10ea735c7f37195053514d01ad9ad5ffeb0f6c519db25d06bee563e84d0691c2da94a4d8500009a9311a834e3d760b0aa96eb5240f129c67856b3416e796a0eb8410dd6442bb61ef1ba9e6d697abf1918f48b62e9a36bb528ff954e9fe1ed32609b78704e80ea68d71cb131701098473c13459c6a7836d2583603b6418e851b68ff5bd22b13096c4ed2574e71bf36afe5c3797c6310553e74c912f78c3b7a2c228e6444ec4b62c29c6456bedbdd06489495fa4a04f0ed137cfcdd0374749c5e03b3cdbeffa68f103128e8a1d6c2390d38adbe0e0c53bd9bdde529e6c51f1be1bad77ac81c71cec6efd03e59ea04b2f050000" ], "rawHeaders": { "anthropic-ratelimit-input-tokens-limit": "10000000", "anthropic-ratelimit-input-tokens-remaining": "10000000", - "anthropic-ratelimit-input-tokens-reset": "2026-07-03T09:47:11Z", + "anthropic-ratelimit-input-tokens-reset": "2026-08-20T13:47:12Z", "anthropic-ratelimit-output-tokens-limit": "2000000", "anthropic-ratelimit-output-tokens-remaining": "2000000", - "anthropic-ratelimit-output-tokens-reset": "2026-07-03T09:47:11Z", + "anthropic-ratelimit-output-tokens-reset": "2026-08-20T13:47:12Z", "anthropic-ratelimit-requests-limit": "20000", "anthropic-ratelimit-requests-remaining": "19999", - "anthropic-ratelimit-requests-reset": "2026-07-03T09:47:11Z", + "anthropic-ratelimit-requests-reset": "2026-08-20T13:47:12Z", "anthropic-ratelimit-tokens-limit": "12000000", "anthropic-ratelimit-tokens-remaining": "12000000", - "anthropic-ratelimit-tokens-reset": "2026-07-03T09:47:11Z", + "anthropic-ratelimit-tokens-reset": "2026-08-20T13:47:12Z", "cache-control": "no-cache", "cf-cache-status": "DYNAMIC", - "cf-ray": "a154f7a65ac52f95-VIE", + "cf-ray": "a2e1d936ff53bb94-PRG", "connection": "keep-alive", "content-encoding": "gzip", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "content-type": "text/event-stream; charset=utf-8", - "date": "Fri, 03 Jul 2026 09:47:12 GMT", - "request-id": "req_011CceurocwFnEVw1JGBfKDc", + "date": "Thu, 20 Aug 2026 13:47:12 GMT", + "request-id": "req_011CeE6zGRp97hjGxRk4t93h", "server": "cloudflare", "strict-transport-security": "max-age=31536000; includeSubDomains; preload", - "traceresponse": "00-962f3f29592dd0795f03734fd76bd982-2df7b75546da1124-01", + "traceresponse": "00-65548e4e6f77edda0b239804578b882d-ed4f8ab94b6ff6a3-01", "transfer-encoding": "chunked", "vary": "Accept-Encoding", "x-robots-tag": "none" diff --git a/packages/@n8n/agents/src/runtime/__tests__/message-list.test.ts b/packages/@n8n/agents/src/runtime/__tests__/message-list.test.ts index 2616a2f14b0..acabeaf0010 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/message-list.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/message-list.test.ts @@ -515,3 +515,37 @@ describe('AgentMessageList — setToolCallError', () => { expect((block as unknown as { output?: unknown }).output).toBeUndefined(); }); }); + +describe('AgentMessageList — markToolCallSuspended', () => { + it('stamps suspension info on a pending tool-call block', () => { + const list = new AgentMessageList(); + list.addResponse([makePendingToolCallMsg('id-1')]); + + list.markToolCallSuspended('id-1', { message: 'Edit My Workflow (ID: abc)?', requestId: 'r1' }); + + const host = list + .turnDelta() + .find((m) => 'content' in m && m.content.some((c) => c.type === 'tool-call')) as Message; + const block = host.content.find((c) => c.type === 'tool-call') as ContentToolCall & { + state: 'pending'; + }; + expect(block.state).toBe('pending'); + expect(block.suspension).toEqual({ message: 'Edit My Workflow (ID: abc)?', requestId: 'r1' }); + }); + + it('is a no-op for settled blocks and unknown ids', () => { + const list = new AgentMessageList(); + list.addResponse([makePendingToolCallMsg('id-1')]); + list.setToolCallResult('id-1', { ok: true }); + + list.markToolCallSuspended('id-1', { message: 'stale' }); + list.markToolCallSuspended('missing', { message: 'stale' }); + + const host = list + .turnDelta() + .find((m) => 'content' in m && m.content.some((c) => c.type === 'tool-call')) as Message; + const block = host.content.find((c) => c.type === 'tool-call') as ContentToolCall; + expect(block.state).toBe('resolved'); + expect('suspension' in block).toBe(false); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/__tests__/strip-orphaned-tool-messages.test.ts b/packages/@n8n/agents/src/runtime/__tests__/strip-orphaned-tool-messages.test.ts index 07032d85e03..cf70bb26df1 100644 --- a/packages/@n8n/agents/src/runtime/__tests__/strip-orphaned-tool-messages.test.ts +++ b/packages/@n8n/agents/src/runtime/__tests__/strip-orphaned-tool-messages.test.ts @@ -1,5 +1,8 @@ -import type { AgentMessage, Message } from '../../types/sdk/message'; -import { stripOrphanedToolMessages } from '../memory/strip-orphaned-tool-messages'; +import type { AgentMessage, ContentToolCall, Message } from '../../types/sdk/message'; +import { + settleOrphanedToolMessages, + stripOrphanedToolMessages, +} from '../memory/strip-orphaned-tool-messages'; describe('stripOrphanedToolMessages', () => { it('returns messages unchanged when all tool-calls are settled', () => { @@ -135,3 +138,168 @@ describe('stripOrphanedToolMessages', () => { expect(result[0]).toBe(customMsg); }); }); + +describe('settleOrphanedToolMessages', () => { + const settledBlock = (result: AgentMessage[], msgIndex: number, blockIndex: number) => + (result[msgIndex] as Message).content[blockIndex] as ContentToolCall; + + it('returns messages unchanged when all tool-calls are settled', () => { + const messages: AgentMessage[] = [ + { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'c1', + toolName: 'lookup', + input: {}, + state: 'resolved', + output: 42, + }, + ], + }, + ]; + + const result = settleOrphanedToolMessages(messages); + expect(result).toEqual(messages); + // Untouched messages are passed through by reference, not rebuilt. + expect(result[1]).toBe(messages[1]); + }); + + it('rejects a pending block with a "did not take effect" record instead of dropping it', () => { + const messages: AgentMessage[] = [ + { role: 'user', content: [{ type: 'text', text: 'Save it' }] }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Saving...' }, + { + type: 'tool-call', + toolCallId: 'c1', + toolName: 'build-workflow', + input: { workflowId: 'wf1' }, + state: 'pending', + }, + ], + }, + ]; + + const result = settleOrphanedToolMessages(messages); + + expect(result).toHaveLength(2); + const msg = result[1] as Message; + expect(msg.content).toHaveLength(2); + const block = settledBlock(result, 1, 1); + expect(block.state).toBe('rejected'); + expect(block.toolCallId).toBe('c1'); + expect(block.input).toEqual({ workflowId: 'wf1' }); + const error = (block as Extract).error; + expect(error).toContain('build-workflow call never completed'); + expect(error).toContain('did NOT take effect'); + }); + + it('quotes the confirmation message when the pending block carries suspension info', () => { + const messages: AgentMessage[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'c1', + toolName: 'build-workflow', + input: {}, + state: 'pending', + suspension: { message: 'Edit My Workflow (ID: abc123)?', requestId: 'req1' }, + }, + ], + }, + ]; + + const result = settleOrphanedToolMessages(messages); + + const block = settledBlock(result, 0, 0); + expect(block.state).toBe('rejected'); + const error = (block as Extract).error; + expect(error).toContain('Edit My Workflow (ID: abc123)?'); + expect(error).toContain('never answered'); + // The suspension marker is consumed into the error, not carried on the settled block. + expect('suspension' in block).toBe(false); + }); + + it('keeps a message whose only content is a pending block (unlike strip)', () => { + const messages: AgentMessage[] = [ + { role: 'user', content: [{ type: 'text', text: 'Do it' }] }, + { + role: 'assistant', + content: [ + { type: 'tool-call', toolCallId: 'c1', toolName: 'action', input: {}, state: 'pending' }, + ], + }, + ]; + + const result = settleOrphanedToolMessages(messages); + + expect(result).toHaveLength(2); + expect(settledBlock(result, 1, 0).state).toBe('rejected'); + }); + + it('settles only pending blocks in a mixed message', () => { + const messages: AgentMessage[] = [ + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'c1', + toolName: 'lookup', + input: {}, + state: 'resolved', + output: 99, + }, + { + type: 'tool-call', + toolCallId: 'c2', + toolName: 'delete', + input: {}, + state: 'pending', + }, + { + type: 'tool-call', + toolCallId: 'c3', + toolName: 'create', + input: {}, + state: 'rejected', + error: 'boom', + }, + ], + }, + ]; + + const result = settleOrphanedToolMessages(messages); + + const blocks = (result[0] as Message).content as ContentToolCall[]; + expect(blocks).toHaveLength(3); + expect(blocks[0].state).toBe('resolved'); + expect(blocks[1].state).toBe('rejected'); + expect((blocks[1] as Extract).error).toContain( + 'never completed', + ); + expect(blocks[2].state).toBe('rejected'); + expect((blocks[2] as Extract).error).toBe('boom'); + }); + + it('preserves custom (non-LLM) messages by reference', () => { + const customMsg: AgentMessage = { + id: 'custom-1', + type: 'custom', + messageType: 'notification', + data: { info: 'hello' }, + } as unknown as AgentMessage; + + const result = settleOrphanedToolMessages([customMsg]); + + expect(result).toHaveLength(1); + expect(result[0]).toBe(customMsg); + }); +}); diff --git a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts index 39c6b273047..c6da9c19f75 100644 --- a/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts +++ b/packages/@n8n/agents/src/runtime/loop/agent-runtime.ts @@ -1067,6 +1067,21 @@ export class AgentRuntime { const executionOptions: PersistedExecutionOptions | undefined = resolvedMaxIterations !== undefined ? { maxIterations: resolvedMaxIterations } : undefined; + // Record what confirmation each suspended call showed the user, so an + // abandoned suspension can be settled with that context on a later + // history load instead of vanishing from the transcript. + for (const pending of Object.values(pendingToolCalls)) { + if (!pending.suspended) continue; + const payload = + typeof pending.suspendPayload === 'object' && pending.suspendPayload !== null + ? (pending.suspendPayload as { message?: unknown; requestId?: unknown }) + : undefined; + list.markToolCallSuspended(pending.toolCallId, { + ...(typeof payload?.message === 'string' ? { message: payload.message } : {}), + ...(typeof payload?.requestId === 'string' ? { requestId: payload.requestId } : {}), + }); + } + const state: SerializableAgentState = { persistence: options?.persistence, status: 'suspended', diff --git a/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts b/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts index ea2a4c8314d..3c1bb99c5b1 100644 --- a/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts +++ b/packages/@n8n/agents/src/runtime/memory/memory-orchestrator.ts @@ -14,7 +14,7 @@ import { runObservationLogReflector } from './observation-log-reflector'; import { renderObservationLog } from './observation-log-renderer'; import { hasObservationLogStore, hasObservationLogTaskLockStore } from './observation-log-store'; import { ScopedMemoryTaskRunner } from './scoped-memory-task-runner'; -import { stripOrphanedToolMessages } from './strip-orphaned-tool-messages'; +import { settleOrphanedToolMessages } from './strip-orphaned-tool-messages'; import type { AgentExecutionCounter, BuiltMemory, @@ -155,7 +155,9 @@ export class MemoryOrchestrator { const memMessages = await this.loadHistoryMessages(options.persistence, telemetry); if (memMessages.length > 0) { - list.addHistory(stripOrphanedToolMessages(memMessages)); + // Settle (not strip) so an abandoned suspension stays visible as an + // explicit "never completed" record instead of vanishing (INS-1223). + list.addHistory(settleOrphanedToolMessages(memMessages)); } } @@ -262,8 +264,8 @@ export class MemoryOrchestrator { * / title jobs that `saveToMemory` schedules; those stay at end-of-turn. Idempotent * with the end-of-turn save: ids are stable across serialize/deserialize, so both * writes target the same rows and TypeORM upserts (pending tool-call → resolved in - * place). A still-pending tool-call left by an abort is stripped on the next load - * (`stripOrphanedToolMessages`), so persisting an incomplete turn can't malform history. + * place). A still-pending tool-call left by an abort is settled on the next load + * (`settleOrphanedToolMessages`), so persisting an incomplete turn can't malform history. */ async persistTurnDelta( list: AgentMessageList, diff --git a/packages/@n8n/agents/src/runtime/memory/strip-orphaned-tool-messages.ts b/packages/@n8n/agents/src/runtime/memory/strip-orphaned-tool-messages.ts index 36bbade0f33..65872591c00 100644 --- a/packages/@n8n/agents/src/runtime/memory/strip-orphaned-tool-messages.ts +++ b/packages/@n8n/agents/src/runtime/memory/strip-orphaned-tool-messages.ts @@ -1,5 +1,56 @@ import { isLlmMessage } from '../../sdk/message'; -import type { AgentMessage, MessageContent } from '../../types/sdk/message'; +import type { AgentMessage, ContentToolCall, MessageContent } from '../../types/sdk/message'; + +/** + * Settle pending tool-call blocks in loaded thread history by rejecting them + * with an explanation that the call never completed. + * + * A pending block in *history* means a previous turn suspended on the call + * (usually awaiting a user confirmation) and was never resumed — the user + * typed a new message instead, the confirmation timed out, or the process + * restarted. Silently dropping the block (what `stripOrphanedToolMessages` + * does for the mid-run path) erases the only evidence the action was ever + * attempted, so the model may describe the action as done or blindly re-fire + * it. Rejecting it with an explicit "did not take effect" record keeps the + * model's view of the world truthful (INS-1223). + * + * Only for history loads. The mid-run serialization path must keep using + * `stripOrphanedToolMessages`: there a pending block is the *current* turn's + * live suspension, which receives its real result on resume. + */ +export function settleOrphanedToolMessages(messages: T[]): T[] { + return messages.map((msg) => { + if (!isLlmMessage(msg)) return msg; + if (!msg.content.some((block) => block.type === 'tool-call' && block.state === 'pending')) { + return msg; + } + + const content = msg.content.map((block: MessageContent) => { + if (block.type !== 'tool-call' || block.state !== 'pending') return block; + const { suspension, ...rest } = block; + return { + ...rest, + state: 'rejected' as const, + error: buildAbandonedSuspensionError(block), + }; + }); + return { ...msg, content }; + }); +} + +function buildAbandonedSuspensionError( + block: Extract, +): string { + const card = block.suspension?.message + ? ` The user was shown a confirmation ("${block.suspension.message}") and never answered it.` + : " It was awaiting the user's confirmation (or the session was interrupted) and was never resumed."; + return ( + `INTERRUPTED — this ${block.toolName} call never completed.${card}` + + ' Its action did NOT take effect: nothing was saved, executed, or applied by this call.' + + ' Do not describe this action as done. If it is still needed, explain to the user that it' + + ' requires their approval and what approving will do, then re-issue it once.' + ); +} /** * Strip pending tool-call blocks from a message list before sending to the LLM. diff --git a/packages/@n8n/agents/src/runtime/model/message-list.ts b/packages/@n8n/agents/src/runtime/model/message-list.ts index 9b341d324bc..16cba46fde7 100644 --- a/packages/@n8n/agents/src/runtime/model/message-list.ts +++ b/packages/@n8n/agents/src/runtime/model/message-list.ts @@ -4,7 +4,12 @@ import type { ModelMessage, SystemModelMessage } from 'ai'; import { toAiMessages } from './messages'; import { filterLlmMessages, getCreatedAt } from '../../sdk/message'; import type { SerializedMessageList } from '../../types/runtime/message-list'; -import type { AgentDbMessage, AgentMessage, ContentToolCall } from '../../types/sdk/message'; +import type { + AgentDbMessage, + AgentMessage, + ContentToolCall, + ToolCallSuspensionInfo, +} from '../../types/sdk/message'; import type { JSONValue } from '../../types/utils/json'; import { stringifyError } from '../loop/runtime-helpers'; import { stripOrphanedToolMessages } from '../memory/strip-orphaned-tool-messages'; @@ -255,6 +260,20 @@ export class AgentMessageList { return host; } + /** + * Record on a pending tool-call block what confirmation the user was shown + * (HITL suspension). No-op when the toolCallId is unknown or the block is + * already settled. Lets a later history load of an abandoned suspension + * explain the unanswered confirmation instead of silently dropping it. + */ + markToolCallSuspended(toolCallId: string, suspension: ToolCallSuspensionInfo): void { + const host = this.findToolCallHost(toolCallId); + if (!host) return; + const block = this.findToolCallBlock(host, toolCallId); + if (!block || block.state !== 'pending') return; + block.suspension = suspension; + } + private findToolCallHost(toolCallId: string): AgentDbMessage | undefined { // Start from the last message and go backwards to find the host message for (let i = this.all.length - 1; i >= 0; i--) { diff --git a/packages/@n8n/agents/src/types/sdk/message.ts b/packages/@n8n/agents/src/types/sdk/message.ts index 33782e8d653..c8e6510c3d5 100644 --- a/packages/@n8n/agents/src/types/sdk/message.ts +++ b/packages/@n8n/agents/src/types/sdk/message.ts @@ -134,11 +134,23 @@ export type ContentToolCall = ContentMetadata & { providerExecuted?: boolean; } & ( - | { state: 'pending' } + | { state: 'pending'; suspension?: ToolCallSuspensionInfo } | { state: 'resolved'; output: JSONValue; canceled?: boolean } | { state: 'rejected'; error: string } ); +/** + * Present on a pending block when the call suspended for user confirmation + * (HITL). Records what was asked of the user, so a later history load can + * settle an abandoned confirmation with an explanation instead of silently + * dropping the call. + */ +export interface ToolCallSuspensionInfo { + /** Human-readable confirmation message shown to the user (e.g. the approval card text). */ + message?: string; + requestId?: string; +} + export type ContentInvalidToolCall = ContentMetadata & { type: 'invalid-tool-call'; diff --git a/packages/@n8n/instance-ai/src/agent/system-prompt.ts b/packages/@n8n/instance-ai/src/agent/system-prompt.ts index 9a0fdb76fc4..6e62bb10a59 100644 --- a/packages/@n8n/instance-ai/src/agent/system-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/system-prompt.ts @@ -156,7 +156,8 @@ ${getToolDiscoverySection(toolSearchEnabled, mcpToolSearchEnabled)} - No emojis unless the user explicitly requests them. - At the beginning of a normal user-visible turn, before your first tool call, write one short sentence explaining what you are about to do or what decision you need. Keep it tied to the user's goal, not the tool name. For system-generated background or checkpoint follow-up turns, follow the follow-up instructions. - Never let an empty assistant message or a \`[Calling tools: ...]\` placeholder be the first visible response. -- End every tool call sequence with a brief text summary — the user cannot see raw tool output. Do not end your turn silently after tool calls. Exception: after calling \`create-tasks\`, or during planned-task build/checkpoint follow-ups, the task card, approval card, or checklist replaces your reply — do not write text. +- End every tool call sequence with a brief text summary — the user cannot see raw tool output. Do not end your turn silently after tool calls. Exception: after calling \`create-tasks\`, or during planned-task build/checkpoint follow-ups, the task card or checklist replaces your reply — do not write text. +- Approval cards are never a reply on their own. Before a tool call that will show an approval card (e.g. saving changes to an existing workflow, publishing, or a live run), write one short sentence saying what the card asks and that nothing happens until they respond to it. If the user seems confused or asks what is happening while an approval is pending, explain in words that the action is waiting for their approval and what approving or denying does — never answer with only a re-issued card. ## Capability Honesty diff --git a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts index 26c14696f4b..b37923a926a 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/build-workflow.tool.ts @@ -599,7 +599,10 @@ export function createBuildWorkflowTool(context: InstanceAiContext) { category: 'blocked', shouldEdit: false, reason: 'user_denied', - guidance: 'The user denied permission to edit this workflow.', + guidance: + 'The user declined the save approval card — nothing was saved. Do not re-issue ' + + 'the same save unprompted: acknowledge the denial, tell the user what remains ' + + 'unsaved, and ask how they want to proceed.', }); trackWorkflowSourceBuild(context, { result: 'denied',