diff --git a/package.json b/package.json index ed8aaec5..5d324b81 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs", "team:prove-agent-cli-launch": "node ./scripts/prove-agent-cli-launch.mjs", "team:prove-provider-launch-stress": "node ./scripts/prove-provider-launch-stress.mjs", - "team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts", + "team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers=1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts", "team:smoke-changes-real-data": "tsx scripts/team-changes-real-data-smoke.ts", "smoke:codex-runtime-install": "tsx scripts/smoke/codex-runtime-install.ts", "prebuild": "node ./scripts/ci/verify-radix-presence-patch.mjs && tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build", @@ -80,7 +80,7 @@ "i18n:validate": "tsx scripts/i18n/validate.ts", "i18n:types": "i18next-cli types --quiet", "test": "vitest run", - "test:ci": "vitest run --maxWorkers 1 --minWorkers 1", + "test:ci": "vitest run --maxWorkers=1", "test:task-change-ledger": "vitest run test/main/services/team/TaskChangeLedgerReader.test.ts test/main/services/team/taskChangeLedgerFixtures.integration.test.ts test/main/services/team/ReviewApplierService.test.ts test/main/services/team/FileContentResolver.test.ts test/main/services/team/ChangeExtractorService.test.ts test/renderer/store/changeReviewSlice.test.ts test/renderer/utils/reviewKey.test.ts test/main/services/team/TeamLogSourceTracker.test.ts test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts", "test:watch": "vitest", "test:coverage": "vitest run --coverage", @@ -393,6 +393,11 @@ }, "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800", "pnpm": { + "auditConfig": { + "ignoreGhsas": [ + "GHSA-5xrq-8626-4rwp" + ] + }, "overrides": { "@hono/node-server@1": "1.19.13", "@xmldom/xmldom": "0.8.13", diff --git a/resources/pricing.json b/resources/pricing.json index 1210abed..fef87fd3 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -37,7 +37,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "anthropic.claude-haiku-4-5@20251001": { @@ -61,7 +60,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_streaming": true, "supports_native_structured_output": true }, @@ -232,8 +230,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "anthropic.claude-opus-4-20250514-v1:0": { "cache_creation_input_token_cost": 0.00001875, @@ -258,8 +255,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 0.00000625, @@ -283,12 +279,12 @@ "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_minimal_reasoning_effort": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "high" }, "anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.00000625, @@ -315,11 +311,10 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, "supports_output_config": true, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "bedrock_output_config_effort_ceiling": "max" }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.00000625, @@ -346,11 +341,10 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, "supports_output_config": true, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "bedrock_output_config_effort_ceiling": "max" }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, @@ -377,11 +371,10 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, "supports_output_config": true, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "bedrock_output_config_effort_ceiling": "max" }, "eu.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, @@ -407,11 +400,10 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, "supports_output_config": true, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "bedrock_output_config_effort_ceiling": "max" }, "au.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, @@ -437,11 +429,10 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, "supports_output_config": true, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "bedrock_output_config_effort_ceiling": "max" }, "anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.00000625, @@ -469,10 +460,10 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" }, "anthropic.claude-mythos-preview": { "input_cost_per_token": 0, @@ -486,8 +477,8 @@ "supports_vision": true, "supports_prompt_caching": false, "supports_reasoning": true, - "supports_minimal_reasoning_effort": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_output_config": true }, "global.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.00000625, @@ -515,10 +506,10 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" }, "us.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.000006875, @@ -546,10 +537,10 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" }, "eu.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.000006875, @@ -576,10 +567,10 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" }, "au.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.000006875, @@ -606,10 +597,165 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "anthropic.claude-opus-4-8": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "global.anthropic.claude-opus-4-8": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "us.anthropic.claude-opus-4-8": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "eu.anthropic.claude-opus-4-8": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" + }, + "au.anthropic.claude-opus-4-8": { + "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 0.0000055, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.0000275, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh" }, "anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, @@ -637,10 +783,8 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_output_config": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true }, "global.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, @@ -668,10 +812,8 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_output_config": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true }, "us.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, @@ -699,10 +841,8 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_output_config": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true }, "eu.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, @@ -729,10 +869,8 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_output_config": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true }, "au.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, @@ -759,10 +897,8 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_output_config": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true }, "jp.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, @@ -789,10 +925,8 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, - "supports_output_config": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true }, "anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 0.00000375, @@ -821,8 +955,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.00000375, @@ -854,7 +987,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159, "supports_native_structured_output": true }, "anthropic.claude-v1": { @@ -947,7 +1079,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "apac.anthropic.claude-3-sonnet-20240229-v1:0": { @@ -993,8 +1124,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "au.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.000004125, @@ -1024,7 +1154,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "azure_ai/claude-haiku-4-5": { @@ -1065,10 +1194,10 @@ "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_minimal_reasoning_effort": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_output_config": true }, "azure_ai/claude-opus-4-6": { "input_cost_per_token": 0.000005, @@ -1095,10 +1224,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159, "supports_output_config": true, - "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_max_reasoning_effort": true }, "azure_ai/claude-opus-4-7": { "input_cost_per_token": 0.000005, @@ -1126,9 +1253,35 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 159, - "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_max_reasoning_effort": true + }, + "azure_ai/claude-opus-4-8": { + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025, + "litellm_provider": "azure_ai", + "max_input_tokens": 200000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true }, "azure_ai/claude-opus-4-1": { "cache_creation_input_token_cost": 0.00001875, @@ -1193,9 +1346,7 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, - "supports_output_config": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true }, "bedrock/ap-northeast-1/anthropic.claude-instant-v1": { "input_cost_per_token": 0.00000223, @@ -1587,8 +1738,7 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true, - "tool_use_system_prompt_tokens": 159 + "supports_web_search": true }, "claude-3-haiku-20240307": { "cache_creation_input_token_cost": 3e-7, @@ -1606,8 +1756,7 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 264 + "supports_vision": true }, "claude-3-opus-20240229": { "cache_creation_input_token_cost": 0.00001875, @@ -1626,8 +1775,7 @@ "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 395 + "supports_vision": true }, "claude-4-opus-20250514": { "cache_creation_input_token_cost": 0.00001875, @@ -1652,8 +1800,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "claude-4-sonnet-20250514": { "cache_creation_input_token_cost": 0.00000375, @@ -1683,8 +1830,7 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true, - "tool_use_system_prompt_tokens": 159 + "supports_web_search": true }, "claude-sonnet-4-5": { "cache_creation_input_token_cost": 0.00000375, @@ -1713,8 +1859,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "supports_vision": true }, "claude-sonnet-4-5-20250929": { "cache_creation_input_token_cost": 0.00000375, @@ -1744,8 +1889,7 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_web_search": true, - "tool_use_system_prompt_tokens": 346 + "supports_web_search": true }, "claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, @@ -1773,9 +1917,7 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, - "supports_output_config": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true }, "claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.00000375, @@ -1799,8 +1941,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "claude-opus-4-1": { "cache_creation_input_token_cost": 0.00001875, @@ -1826,8 +1967,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "claude-opus-4-1-20250805": { "cache_creation_input_token_cost": 0.00001875, @@ -1854,8 +1994,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "claude-opus-4-20250514": { "cache_creation_input_token_cost": 0.00001875, @@ -1882,8 +2021,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "claude-opus-4-5-20251101": { "cache_creation_input_token_cost": 0.00000625, @@ -1907,11 +2045,10 @@ "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_minimal_reasoning_effort": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_output_config": true }, "claude-opus-4-5": { "cache_creation_input_token_cost": 0.00000625, @@ -1935,11 +2072,10 @@ "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_minimal_reasoning_effort": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_output_config": true }, "claude-opus-4-6": { "cache_creation_input_token_cost": 0.00000625, @@ -1967,14 +2103,12 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, "fast": 6 }, "supports_output_config": true, - "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_max_reasoning_effort": true }, "claude-opus-4-6-20260205": { "cache_creation_input_token_cost": 0.00000625, @@ -2002,13 +2136,11 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, "fast": 6 }, "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true, "supports_output_config": true }, "claude-opus-4-7": { @@ -2039,12 +2171,10 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, "fast": 6 }, - "supports_minimal_reasoning_effort": true, "supports_output_config": true }, "claude-opus-4-7-20260416": { @@ -2075,12 +2205,44 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346, "provider_specific_entry": { "us": 1.1, "fast": 6 }, - "supports_minimal_reasoning_effort": true, + "supports_output_config": true + }, + "claude-opus-4-8": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true, + "provider_specific_entry": { + "us": 1.1, + "fast": 2 + }, "supports_output_config": true }, "claude-sonnet-4-20250514": { @@ -2112,8 +2274,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "databricks/databricks-claude-3-7-sonnet": { "input_cost_per_token": 0.0000029999900000000002, @@ -2208,8 +2369,8 @@ "supports_assistant_prefill": true, "supports_function_calling": true, "supports_reasoning": true, - "supports_minimal_reasoning_effort": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_output_config": true }, "databricks/databricks-claude-sonnet-4": { "input_cost_per_token": 0.0000029999900000000002, @@ -2340,7 +2501,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "eu.anthropic.claude-3-5-sonnet-20240620-v1:0": { @@ -2468,8 +2628,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "eu.anthropic.claude-opus-4-20250514-v1:0": { "cache_creation_input_token_cost": 0.00001875, @@ -2494,8 +2653,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "eu.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 0.00000375, @@ -2524,8 +2682,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.000004125, @@ -2555,7 +2712,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "gemini-2.5-flash": { @@ -2721,7 +2877,7 @@ "output_cost_per_token": 0.000025, "supports_function_calling": true, "supports_vision": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true }, "gmi/anthropic/claude-sonnet-4.5": { "input_cost_per_token": 0.000003, @@ -2786,7 +2942,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "global.anthropic.claude-sonnet-4-20250514-v1:0": { @@ -2816,8 +2971,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "global.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.00000125, @@ -2840,7 +2994,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "gpt-5.2": { @@ -3214,7 +3367,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "jp.anthropic.claude-haiku-4-5-20251001-v1:0": { @@ -3237,7 +3389,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "openrouter/anthropic/claude-3-haiku": { @@ -3265,8 +3416,7 @@ "supports_computer_use": true, "supports_function_calling": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "openrouter/anthropic/claude-3.7-sonnet": { "input_cost_per_image": 0.0048, @@ -3282,8 +3432,7 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "openrouter/anthropic/claude-opus-4": { "input_cost_per_image": 0.0048, @@ -3302,8 +3451,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "openrouter/anthropic/claude-opus-4.1": { "input_cost_per_image": 0.0048, @@ -3323,8 +3471,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "openrouter/anthropic/claude-sonnet-4": { "input_cost_per_image": 0.0048, @@ -3347,8 +3494,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "openrouter/anthropic/claude-sonnet-4.6": { "cache_creation_input_token_cost": 0.00000375, @@ -3372,9 +3518,7 @@ "supports_reasoning": true, "supports_max_reasoning_effort": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159, - "supports_minimal_reasoning_effort": true + "supports_vision": true }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 0.00000625, @@ -3389,12 +3533,11 @@ "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, - "supports_minimal_reasoning_effort": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_output_config": true }, "openrouter/anthropic/claude-opus-4.6": { "cache_creation_input_token_cost": 0.00000625, @@ -3413,9 +3556,7 @@ "supports_reasoning": true, "supports_max_reasoning_effort": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346, - "supports_minimal_reasoning_effort": true + "supports_vision": true }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, @@ -3438,8 +3579,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "openrouter/anthropic/claude-haiku-4.5": { "cache_creation_input_token_cost": 0.00000125, @@ -3457,8 +3597,7 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 346 + "supports_vision": true }, "openrouter/anthropic/claude-opus-4.7": { "cache_creation_input_token_cost": 0.00000625, @@ -3480,8 +3619,7 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346 + "supports_xhigh_reasoning_effort": true }, "replicate/anthropic/claude-4.5-haiku": { "input_cost_per_token": 0.000001, @@ -3599,7 +3737,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "us.anthropic.claude-3-5-sonnet-20240620-v1:0": { @@ -3727,8 +3864,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "us.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.000004125, @@ -3760,7 +3896,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": { @@ -3786,7 +3921,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "au.anthropic.claude-haiku-4-5-20251001-v1:0": { @@ -3808,7 +3942,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true }, "us.anthropic.claude-opus-4-20250514-v1:0": { @@ -3834,8 +3967,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "us.anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 0.000006875, @@ -3856,15 +3988,15 @@ "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, - "supports_minimal_reasoning_effort": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "high" }, "global.anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 0.00000625, @@ -3885,15 +4017,15 @@ "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, - "supports_minimal_reasoning_effort": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "high" }, "eu.anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 0.00000625, @@ -3913,15 +4045,15 @@ "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, - "supports_minimal_reasoning_effort": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "high" }, "us.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 0.00000375, @@ -3950,8 +4082,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "vercel_ai_gateway/anthropic/claude-3-haiku": { "cache_creation_input_token_cost": 3e-7, @@ -4180,13 +4311,13 @@ "output_cost_per_token": 0.000025, "supports_assistant_prefill": true, "supports_computer_use": true, - "supports_minimal_reasoning_effort": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "supports_output_config": true }, "vercel_ai_gateway/anthropic/claude-opus-4.6": { "cache_creation_input_token_cost": 0.00000625, @@ -4206,7 +4337,7 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true }, "vercel_ai_gateway/anthropic/claude-sonnet-4": { "cache_creation_input_token_cost": 0.00000375, @@ -4361,8 +4492,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "vertex_ai/claude-3-haiku": { "input_cost_per_token": 2.5e-7, @@ -4465,8 +4595,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "vertex_ai/claude-opus-4-1": { "cache_creation_input_token_cost": 0.00001875, @@ -4520,14 +4649,13 @@ "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, - "supports_minimal_reasoning_effort": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_output_config": true }, "vertex_ai/claude-opus-4-5@20251101": { "cache_creation_input_token_cost": 0.00000625, @@ -4547,15 +4675,14 @@ "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, - "supports_minimal_reasoning_effort": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 159, - "supports_native_streaming": true + "supports_native_streaming": true, + "supports_output_config": true }, "vertex_ai/claude-opus-4-6": { "cache_creation_input_token_cost": 0.00000625, @@ -4581,10 +4708,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_output_config": true, - "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_max_reasoning_effort": true }, "vertex_ai/claude-opus-4-6@default": { "cache_creation_input_token_cost": 0.00000625, @@ -4610,10 +4735,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_output_config": true, - "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_max_reasoning_effort": true }, "vertex_ai/claude-opus-4-7": { "cache_creation_input_token_cost": 0.00000625, @@ -4640,9 +4763,7 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_max_reasoning_effort": true }, "vertex_ai/claude-opus-4-7@default": { "cache_creation_input_token_cost": 0.00000625, @@ -4669,9 +4790,63 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "tool_use_system_prompt_tokens": 346, - "supports_max_reasoning_effort": true, - "supports_minimal_reasoning_effort": true + "supports_max_reasoning_effort": true + }, + "vertex_ai/claude-opus-4-8": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true + }, + "vertex_ai/claude-opus-4-8@default": { + "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 0.000005, + "litellm_provider": "vertex_ai-anthropic_models", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000025, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_max_reasoning_effort": true }, "vertex_ai/claude-sonnet-4-5": { "cache_creation_input_token_cost": 0.00000375, @@ -4719,14 +4894,12 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_output_config": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true }, "vertex_ai/claude-sonnet-4-5@20250929": { "cache_creation_input_token_cost": 0.00000375, @@ -4778,8 +4951,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "vertex_ai/claude-sonnet-4": { "cache_creation_input_token_cost": 0.00000375, @@ -4808,8 +4980,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "vertex_ai/claude-sonnet-4@20250514": { "cache_creation_input_token_cost": 0.00000375, @@ -4838,8 +5009,7 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 + "supports_vision": true }, "vertex_ai/claude-sonnet-4-6@default": { "cache_creation_input_token_cost": 0.00000375, @@ -4861,14 +5031,12 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, - "supports_output_config": true, - "supports_minimal_reasoning_effort": true + "supports_output_config": true }, "bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.0000015, @@ -4889,7 +5057,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, "supports_pdf_input": true }, @@ -4912,7 +5079,6 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "tool_use_system_prompt_tokens": 346, "supports_native_structured_output": true, "supports_pdf_input": true }, diff --git a/scripts/prove-agent-cli-launch.mjs b/scripts/prove-agent-cli-launch.mjs index 980f0169..8cf3035d 100644 --- a/scripts/prove-agent-cli-launch.mjs +++ b/scripts/prove-agent-cli-launch.mjs @@ -32,10 +32,7 @@ const result = spawnSyncWithWindowsShell( 'exec', 'vitest', 'run', - '--maxWorkers', - '1', - '--minWorkers', - '1', + '--maxWorkers=1', 'test/main/utils/AgentCliLaunch.live-e2e.test.ts', ], { diff --git a/scripts/prove-opencode-mixed-recovery.mjs b/scripts/prove-opencode-mixed-recovery.mjs index 59845aa2..c2201aa1 100644 --- a/scripts/prove-opencode-mixed-recovery.mjs +++ b/scripts/prove-opencode-mixed-recovery.mjs @@ -46,10 +46,7 @@ const result = spawnSyncWithWindowsShell( 'exec', 'vitest', 'run', - '--maxWorkers', - '1', - '--minWorkers', - '1', + '--maxWorkers=1', 'test/main/services/team/OpenCodeMixedRecovery.live.test.ts', ], { diff --git a/scripts/prove-opencode-semantic-gauntlet.mjs b/scripts/prove-opencode-semantic-gauntlet.mjs index 8366d98b..145e677e 100644 --- a/scripts/prove-opencode-semantic-gauntlet.mjs +++ b/scripts/prove-opencode-semantic-gauntlet.mjs @@ -52,10 +52,7 @@ const result = spawnSyncWithWindowsShell( 'exec', 'vitest', 'run', - '--maxWorkers', - '1', - '--minWorkers', - '1', + '--maxWorkers=1', 'test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts', ], { diff --git a/scripts/prove-opencode-semantic-messaging.mjs b/scripts/prove-opencode-semantic-messaging.mjs index a62877a8..33546b5d 100644 --- a/scripts/prove-opencode-semantic-messaging.mjs +++ b/scripts/prove-opencode-semantic-messaging.mjs @@ -44,10 +44,7 @@ const result = spawnSyncWithWindowsShell( 'exec', 'vitest', 'run', - '--maxWorkers', - '1', - '--minWorkers', - '1', + '--maxWorkers=1', 'test/main/services/team/OpenCodeSemanticMessaging.live.test.ts', ], { diff --git a/scripts/prove-opencode-semantic-model-matrix.mjs b/scripts/prove-opencode-semantic-model-matrix.mjs index 1b74e5c6..9ed464bc 100644 --- a/scripts/prove-opencode-semantic-model-matrix.mjs +++ b/scripts/prove-opencode-semantic-model-matrix.mjs @@ -42,10 +42,7 @@ const result = spawnSyncWithWindowsShell( 'exec', 'vitest', 'run', - '--maxWorkers', - '1', - '--minWorkers', - '1', + '--maxWorkers=1', 'test/main/services/team/OpenCodeSemanticModelMatrix.live.test.ts', ], { diff --git a/scripts/prove-opencode-team-provisioning.mjs b/scripts/prove-opencode-team-provisioning.mjs index 5cca86a3..42b2977f 100644 --- a/scripts/prove-opencode-team-provisioning.mjs +++ b/scripts/prove-opencode-team-provisioning.mjs @@ -44,10 +44,7 @@ const result = spawnSyncWithWindowsShell( 'exec', 'vitest', 'run', - '--maxWorkers', - '1', - '--minWorkers', - '1', + '--maxWorkers=1', 'test/main/services/team/OpenCodeTeamProvisioning.live.test.ts', ], { diff --git a/scripts/prove-provider-launch-stress.mjs b/scripts/prove-provider-launch-stress.mjs index 79665188..4753c60c 100644 --- a/scripts/prove-provider-launch-stress.mjs +++ b/scripts/prove-provider-launch-stress.mjs @@ -76,10 +76,7 @@ const result = spawnSyncWithWindowsShell( 'exec', 'vitest', 'run', - '--maxWorkers', - '1', - '--minWorkers', - '1', + '--maxWorkers=1', 'test/main/services/team/ProviderLaunchStress.live-e2e.test.ts', ], { diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index d805172b..978dc70a 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -702,13 +702,13 @@ export class TeamGraphAdapter { if (!normalized) return null; const canonicalTaskIds = taskIdsByCanonicalReference.get(normalized); if (canonicalTaskIds?.size === 1) { - return [...canonicalTaskIds][0]!; + return [...canonicalTaskIds][0]; } if (canonicalTaskIds && canonicalTaskIds.size > 1) { return null; } const displayTaskIds = taskIdsByDisplayReference.get(normalized); - return displayTaskIds?.size === 1 ? [...displayTaskIds][0]! : null; + return displayTaskIds?.size === 1 ? [...displayTaskIds][0] : null; }; const formatTaskReference = (reference: string): string => { const taskId = resolveTaskReference(reference); diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index d2597252..c72144e7 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -334,4 +334,5 @@ export interface MemberWorkSyncOutboxCountRecentDeliveredInput { teamName: string; memberName: string; sinceIso: string; + workSyncIntentKeyPrefix?: string; } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index 42b3d606..dcbc4f3e 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -14,6 +14,7 @@ import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2; const MEMBER_WORK_SYNC_RETRY_BASE_MINUTES = 10; const MEMBER_WORK_SYNC_RETRY_MAX_MINUTES = 60; +const AGENDA_SYNC_STILL_STUCK_RECOVERY_INTENT_PREFIX = 'agenda-sync-still-stuck:'; export interface MemberWorkSyncNudgeDispatchSummary { claimed: number; @@ -76,6 +77,13 @@ function isStatusOnlyRecoveryOutboxItem(item: MemberWorkSyncOutboxItem): boolean return item.payload.workSyncIntentKey?.startsWith('status-only:') === true; } +function isAgendaSyncStillStuckRecoveryOutboxItem(item: MemberWorkSyncOutboxItem): boolean { + return ( + item.payload.workSyncIntentKey?.startsWith(AGENDA_SYNC_STILL_STUCK_RECOVERY_INTENT_PREFIX) === + true + ); +} + function getPayloadReviewRequestEventIds(item: MemberWorkSyncOutboxItem): string[] { return [...new Set(item.payload.workSyncReviewRequestEventIds ?? [])] .filter((id) => id.length > 0) @@ -488,6 +496,9 @@ export class MemberWorkSyncNudgeDispatcher { teamName: item.teamName, memberName: item.memberName, sinceIso: subtractMinutes(nowIso, 60), + ...(isAgendaSyncStillStuckRecoveryOutboxItem(item) + ? { workSyncIntentKeyPrefix: AGENDA_SYNC_STILL_STUCK_RECOVERY_INTENT_PREFIX } + : {}), }); if ( recentDelivered != null && diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts index 03f44fbd..db6c3a8d 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeOutboxPlanner.ts @@ -5,13 +5,25 @@ import { } from '../domain'; import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit'; -import { decideMemberWorkSyncNudgeActivation } from './MemberWorkSyncNudgeActivationPolicy'; +import { + decideMemberWorkSyncNudgeActivation, + type MemberWorkSyncNudgeActivationReason, +} from './MemberWorkSyncNudgeActivationPolicy'; -import type { MemberWorkSyncOutboxEnsureInput, MemberWorkSyncStatus } from '../../contracts'; +import type { + MemberWorkSyncOutboxEnsureInput, + MemberWorkSyncOutboxItem, + MemberWorkSyncStatus, +} from '../../contracts'; import type { MemberWorkSyncUseCaseDeps } from './ports'; const STATUS_ONLY_RECOVERY_INTENT_PREFIX = 'status-only'; const AGENDA_SYNC_REFRESH_INTENT_PREFIX = 'agenda-sync-refresh'; +const DELIVERED_STILL_STUCK_RECOVERY_INTENT_PREFIX = 'agenda-sync-still-stuck'; +const DELIVERED_STILL_STUCK_RECOVERY_MIN_AGE_MS = 6 * 60_000; +const DELIVERED_STILL_STUCK_RECOVERY_BUCKET_MS = 30 * 60_000; +const DELIVERED_STILL_STUCK_RECOVERY_DELIVERY_WINDOW_MS = 60 * 60_000; +const DELIVERED_STILL_STUCK_RECOVERY_MAX_DELIVERED_PER_WINDOW = 2; function getReviewRequestEventIds(status: MemberWorkSyncStatus): string[] { return [ @@ -76,6 +88,73 @@ function shouldPlanAgendaSyncRefreshRecovery(input: { ); } +function parseTime(value: string | undefined): number | null { + if (!value) { + return null; + } + const time = Date.parse(value); + return Number.isFinite(time) ? time : null; +} + +function isDeliveredStillStuckRecoveryReason(reason: MemberWorkSyncNudgeActivationReason): boolean { + return ( + reason === 'shadow_ready' || + reason === 'native_stale_in_progress' || + reason === 'native_stale_assigned_work' || + reason === 'opencode_targeted_shadow_collecting' || + reason === 'lead_targeted_shadow_collecting' || + reason === 'native_targeted_shadow_collecting' + ); +} + +function shouldPlanDeliveredStillStuckRecovery(input: { + status: MemberWorkSyncStatus; + baseInput: MemberWorkSyncOutboxEnsureInput; + existingItem: MemberWorkSyncOutboxItem; + activationReason: MemberWorkSyncNudgeActivationReason; +}): boolean { + const recoverableExistingItem = + input.existingItem.status === 'delivered' || + (input.existingItem.status === 'failed_terminal' && + input.existingItem.lastError === 'inbox_payload_conflict'); + + if ( + input.status.state !== 'needs_sync' || + input.status.shadow?.wouldNudge !== true || + input.baseInput.payload.workSyncIntent !== 'agenda_sync' || + input.baseInput.payload.workSyncIntentKey !== undefined || + !recoverableExistingItem || + input.existingItem.agendaFingerprint !== input.baseInput.agendaFingerprint || + input.status.report?.accepted === true || + !isDeliveredStillStuckRecoveryReason(input.activationReason) + ) { + return false; + } + + const deliveredAtMs = parseTime(input.existingItem.updatedAt); + const evaluatedAtMs = parseTime(input.status.evaluatedAt); + return ( + deliveredAtMs != null && + evaluatedAtMs != null && + evaluatedAtMs - deliveredAtMs >= DELIVERED_STILL_STUCK_RECOVERY_MIN_AGE_MS + ); +} + +function isOutboxItemAwaitingDelivery(item: MemberWorkSyncOutboxItem): boolean { + return item.status !== 'delivered' && item.status !== 'failed_terminal'; +} + +function getDeliveredStillStuckRecoveryBucket(status: MemberWorkSyncStatus): string | null { + const evaluatedAtMs = parseTime(status.evaluatedAt); + if (evaluatedAtMs == null) { + return null; + } + const bucketMs = + Math.floor(evaluatedAtMs / DELIVERED_STILL_STUCK_RECOVERY_BUCKET_MS) * + DELIVERED_STILL_STUCK_RECOVERY_BUCKET_MS; + return new Date(bucketMs).toISOString(); +} + export interface MemberWorkSyncNudgeOutboxPlanResult { planned: boolean; code: @@ -151,9 +230,39 @@ export class MemberWorkSyncNudgeOutboxPlanner { }; } + private buildDeliveredStillStuckRecoveryInput( + status: MemberWorkSyncStatus, + baseInput: MemberWorkSyncOutboxEnsureInput, + bucket: string + ): MemberWorkSyncOutboxEnsureInput { + const intentKey = `${DELIVERED_STILL_STUCK_RECOVERY_INTENT_PREFIX}:${status.agenda.fingerprint}:${baseInput.payloadHash}:${bucket}`; + const payload = { + ...baseInput.payload, + workSyncIntentKey: intentKey, + text: [ + 'Work sync retry: the previous work-sync nudge for this agenda is still stuck and still no accepted member_work_sync_report exists.', + 'Use this latest nudge as the current required sync action.', + baseInput.payload.text, + ].join('\n'), + }; + + return { + ...baseInput, + id: buildMemberWorkSyncNudgeId({ + teamName: status.teamName, + memberName: status.memberName, + agendaFingerprint: status.agenda.fingerprint, + intentKey, + }), + payload, + payloadHash: buildMemberWorkSyncNudgePayloadHash(this.deps.hash, payload), + }; + } + private async planStatusOnlyRecovery( status: MemberWorkSyncStatus, - baseInput: MemberWorkSyncOutboxEnsureInput + baseInput: MemberWorkSyncOutboxEnsureInput, + activationReason?: MemberWorkSyncNudgeActivationReason ): Promise { const outboxStore = this.deps.outboxStore; if (!outboxStore) { @@ -173,7 +282,82 @@ export class MemberWorkSyncNudgeOutboxPlanner { return { planned: false, code: 'payload_conflict' }; } - const recoveryPlanned = recoveryResult.item.status !== 'delivered'; + if (activationReason) { + const deliveredStillStuckRecovery = await this.planDeliveredStillStuckRecovery( + status, + baseInput, + recoveryResult.item, + activationReason + ); + if (deliveredStillStuckRecovery) { + return deliveredStillStuckRecovery; + } + } + + const recoveryPlanned = isOutboxItemAwaitingDelivery(recoveryResult.item); + const recoveryPlanResult = { + planned: recoveryPlanned, + code: recoveryResult.outcome, + } as const; + await this.appendPlanAudit(status, recoveryPlanResult); + return recoveryPlanResult; + } + + private async planDeliveredStillStuckRecovery( + status: MemberWorkSyncStatus, + baseInput: MemberWorkSyncOutboxEnsureInput, + existingItem: MemberWorkSyncOutboxItem, + activationReason: MemberWorkSyncNudgeActivationReason + ): Promise { + const outboxStore = this.deps.outboxStore; + if (!outboxStore) { + return { planned: false, code: 'outbox_unavailable' }; + } + if ( + !shouldPlanDeliveredStillStuckRecovery({ + status, + baseInput, + existingItem, + activationReason, + }) + ) { + return null; + } + + const bucket = getDeliveredStillStuckRecoveryBucket(status); + const evaluatedAtMs = parseTime(status.evaluatedAt); + if (!bucket || evaluatedAtMs == null) { + await this.appendPlanAudit(status, { planned: false, code: 'existing' }); + return { planned: false, code: 'existing' }; + } + const recentDelivered = await outboxStore.countRecentDelivered({ + teamName: status.teamName, + memberName: status.memberName, + sinceIso: new Date( + evaluatedAtMs - DELIVERED_STILL_STUCK_RECOVERY_DELIVERY_WINDOW_MS + ).toISOString(), + workSyncIntentKeyPrefix: `${DELIVERED_STILL_STUCK_RECOVERY_INTENT_PREFIX}:`, + }); + if (recentDelivered >= DELIVERED_STILL_STUCK_RECOVERY_MAX_DELIVERED_PER_WINDOW) { + await this.appendPlanAudit(status, { planned: false, code: 'existing' }); + return { planned: false, code: 'existing' }; + } + + const recoveryInput = this.buildDeliveredStillStuckRecoveryInput(status, baseInput, bucket); + const recoveryResult = await outboxStore.ensurePending(recoveryInput); + if (!recoveryResult.ok) { + this.deps.logger?.warn('member work sync delivered-still-stuck recovery payload conflict', { + teamName: status.teamName, + memberName: status.memberName, + outboxId: recoveryInput.id, + existingPayloadHash: recoveryResult.existingPayloadHash, + requestedPayloadHash: recoveryResult.requestedPayloadHash, + }); + await this.appendPlanAudit(status, { planned: false, code: 'payload_conflict' }); + return { planned: false, code: 'payload_conflict' }; + } + + const recoveryPlanned = isOutboxItemAwaitingDelivery(recoveryResult.item); const recoveryPlanResult = { planned: recoveryPlanned, code: recoveryResult.outcome, @@ -299,10 +483,19 @@ export class MemberWorkSyncNudgeOutboxPlanner { existingItemStatus: recoveryResult.item.status, }) ) { - return this.planStatusOnlyRecovery(status, input); + return this.planStatusOnlyRecovery(status, input, activation.reason); + } + const deliveredStillStuckRecovery = await this.planDeliveredStillStuckRecovery( + status, + input, + recoveryResult.item, + activation.reason + ); + if (deliveredStillStuckRecovery) { + return deliveredStillStuckRecovery; } - const recoveryPlanned = recoveryResult.item.status !== 'delivered'; + const recoveryPlanned = isOutboxItemAwaitingDelivery(recoveryResult.item); const recoveryPlanResult = { planned: recoveryPlanned, code: recoveryResult.outcome, @@ -310,6 +503,15 @@ export class MemberWorkSyncNudgeOutboxPlanner { await this.appendPlanAudit(status, recoveryPlanResult); return recoveryPlanResult; } + const deliveredStillStuckRecovery = await this.planDeliveredStillStuckRecovery( + status, + input, + result.item, + activation.reason + ); + if (deliveredStillStuckRecovery) { + return deliveredStillStuckRecovery; + } this.deps.logger?.warn('member work sync nudge outbox payload conflict', { teamName: status.teamName, memberName: status.memberName, @@ -334,7 +536,16 @@ export class MemberWorkSyncNudgeOutboxPlanner { existingItemStatus: result.item.status, }) ) { - return this.planStatusOnlyRecovery(status, input); + return this.planStatusOnlyRecovery(status, input, activation.reason); + } + const deliveredStillStuckRecovery = await this.planDeliveredStillStuckRecovery( + status, + input, + result.item, + activation.reason + ); + if (deliveredStillStuckRecovery) { + return deliveredStillStuckRecovery; } if ( input.payload.workSyncIntent === 'review_pickup' && @@ -346,7 +557,10 @@ export class MemberWorkSyncNudgeOutboxPlanner { return { planned: false, code }; } - const planResult = { planned: true, code: result.outcome } as const; + const planResult = { + planned: isOutboxItemAwaitingDelivery(result.item), + code: result.outcome, + } as const; await this.appendPlanAudit(status, planResult); return planResult; } diff --git a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts index 9bc2f2e7..96367a54 100644 --- a/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts +++ b/src/features/member-work-sync/core/domain/ActionableWorkAgenda.ts @@ -124,13 +124,13 @@ function findUniqueReferencedTask( const normalized = reference.trim().replace(/^#/, ''); const canonicalMatches = tasksByReference.canonical.get(normalized); if (canonicalMatches?.size === 1) { - return [...canonicalMatches][0]!; + return [...canonicalMatches][0]; } if (canonicalMatches && canonicalMatches.size > 1) { return null; } const matches = tasksByReference.display.get(normalized); - return matches?.size === 1 ? [...matches][0]! : null; + return matches?.size === 1 ? [...matches][0] : null; } export function buildActionableWorkAgenda( diff --git a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts index b0d74322..1182a4c7 100644 --- a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver.ts @@ -183,7 +183,7 @@ export class MemberWorkSyncTaskImpactResolver { diagnostics: ['task_reference_ambiguous'], }; } - const task = matchingTasks[0]!; + const task = matchingTasks[0]; addMember(task.owner); diff --git a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts index fa68966f..4904265a 100644 --- a/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts +++ b/src/features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore.ts @@ -263,30 +263,6 @@ function canClaimOutboxItem(item: MemberWorkSyncOutboxItem, nowIso: string): boo return item.nextAttemptAt <= nowIso; } -function getReviewPickupIntentKey(item: Pick): string | null { - if (item.payload.workSyncIntent !== 'review_pickup') { - return null; - } - const explicit = item.payload.workSyncIntentKey?.trim(); - if (explicit) { - return explicit; - } - const requestEventIds = [...new Set(item.payload.workSyncReviewRequestEventIds ?? [])] - .map((id) => id.trim()) - .filter(Boolean) - .sort(); - return requestEventIds.length > 0 ? `review-pickup:${requestEventIds.join('+')}` : null; -} - -function isSameReviewPickupIntent( - current: MemberWorkSyncOutboxItem, - input: MemberWorkSyncOutboxEnsureInput -): boolean { - const currentIntentKey = getReviewPickupIntentKey(current); - const inputIntentKey = getReviewPickupIntentKey({ payload: input.payload }); - return Boolean(currentIntentKey && inputIntentKey && currentIntentKey === inputIntentKey); -} - function getDueOutboxRoutes( index: OutboxIndexFile, nowIso: string, @@ -732,13 +708,17 @@ export class JsonMemberWorkSyncStore const current = outbox.items[input.id]; if (current) { if (current.payloadHash !== input.payloadHash) { - if (isSameReviewPickupIntent(current, input) && !isOutboxTerminal(current.status)) { + if (current.status !== 'delivered' && current.status !== 'failed_terminal') { const next: MemberWorkSyncOutboxItem = { ...current, agendaFingerprint: input.agendaFingerprint, payloadHash: input.payloadHash, payload: input.payload, status: 'pending', + attemptGeneration: + current.status === 'claimed' + ? current.attemptGeneration + 1 + : current.attemptGeneration, updatedAt: input.nowIso, }; applyOptionalNextAttemptAt(next, input.nextAttemptAt); @@ -884,6 +864,7 @@ export class JsonMemberWorkSyncStore updatedAt: input.nowIso, }; delete next.lastError; + delete next.nextAttemptAt; return next; }); } @@ -924,6 +905,17 @@ export class JsonMemberWorkSyncStore async countRecentDelivered( input: MemberWorkSyncOutboxCountRecentDeliveredInput ): Promise { + const workSyncIntentKeyPrefix = input.workSyncIntentKeyPrefix?.trim(); + if (workSyncIntentKeyPrefix) { + const memberOutbox = await this.readMemberOutboxFile(input.teamName, input.memberName); + return Object.values(memberOutbox.items).filter( + (item) => + item.status === 'delivered' && + item.updatedAt >= input.sinceIso && + item.payload.workSyncIntentKey?.startsWith(workSyncIntentKeyPrefix) === true + ).length; + } + let index = await this.readOutboxIndexFile(input.teamName); if (Object.keys(index.items).length === 0) { await this.enqueue(input.teamName, async () => { diff --git a/src/main/index.ts b/src/main/index.ts index 48c40315..af6bb697 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1934,16 +1934,19 @@ async function initializeServices(): Promise { resolveControlUrl: async () => getTeamControlApiBaseUrl(), proofMissingRecoveryGuard: { shouldDispatch: async (input) => { + const isOpenCodeRecipient = await teamProvisioningService + .isOpenCodeRuntimeRecipient(input.teamName, input.memberName) + .catch(() => false); + if (!isOpenCodeRecipient) { + return { ok: true }; + } + const status = await teamProvisioningService.getOpenCodeRuntimeDeliveryStatus( input.teamName, input.originalMessageId ); if (!status) { - return { - ok: false, - reason: 'proof_missing_recovery_record_missing', - retryable: false, - }; + return { ok: true }; } const impact = status.userVisibleImpact; @@ -2127,6 +2130,21 @@ async function initializeServices(): Promise { ? memberWorkSyncFeature.scheduleProofMissingRecovery(input) : Promise.resolve({ scheduled: false, reason: 'invalid' }) ); + teamProvisioningService.setMemberWorkSyncAcceptedReportChecker(async (input) => { + if (!memberWorkSyncFeature) { + return false; + } + const status = await memberWorkSyncFeature.getStatus(input); + const report = status.report; + if (report?.accepted !== true || report.agendaFingerprint !== status.agenda.fingerprint) { + return false; + } + if (report.state !== 'still_working' && report.state !== 'blocked') { + return true; + } + const expiresAtMs = Date.parse(report.expiresAt ?? ''); + return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now(); + }); scheduleStartupTask(() => { void teamDataService .listTeams() diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts index 1de8dd75..088b4e06 100644 --- a/src/main/ipc/utility.ts +++ b/src/main/ipc/utility.ts @@ -8,8 +8,6 @@ * - read-mentioned-file: Validates mentioned files for context injection */ -import type { AgentConfig } from '@shared/types/api'; - import { createLogger } from '@shared/utils/logger'; import { app, type IpcMain, type IpcMainInvokeEvent, net, shell } from 'electron'; import * as fsp from 'fs/promises'; @@ -27,6 +25,8 @@ import { } from '../utils/pathValidation'; import { countTokens } from '../utils/tokenizer'; +import type { AgentConfig } from '@shared/types/api'; + const logger = createLogger('IPC:utility'); const DISCORD_INVITE_COUNT_URL = 'https://discord.com/api/v10/invites/qtqSZSyuEc?with_counts=true'; const DISCORD_MEMBER_COUNT_CACHE_TTL_MS = 10 * 60 * 1000; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f93b2f4d..032e27cc 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3356,6 +3356,11 @@ type MemberWorkSyncProofMissingRecoveryScheduler = (input: { reason?: string; }) => Promise | unknown; +type MemberWorkSyncAcceptedReportChecker = (input: { + teamName: string; + memberName: string; +}) => Promise | boolean; + function normalizeSameTeamText(text: string): string { return text.trim().replace(/\r\n/g, '\n'); } @@ -3649,6 +3654,7 @@ export class TeamProvisioningService { | null = null; private memberWorkSyncProofMissingRecoveryScheduler: MemberWorkSyncProofMissingRecoveryScheduler | null = null; + private memberWorkSyncAcceptedReportChecker: MemberWorkSyncAcceptedReportChecker | null = null; private readonly memberLogsFinder: TeamMemberLogsFinder; private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService(); @@ -4022,6 +4028,12 @@ export class TeamProvisioningService { this.memberWorkSyncProofMissingRecoveryScheduler = scheduler; } + setMemberWorkSyncAcceptedReportChecker( + checker: MemberWorkSyncAcceptedReportChecker | null + ): void { + this.memberWorkSyncAcceptedReportChecker = checker; + } + setCrossTeamSender( sender: | ((request: { @@ -5517,17 +5529,26 @@ export class TeamProvisioningService { return (await this.resolveRuntimeRecipientProviderId(teamName, memberName)) === 'opencode'; } - private isOpenCodeDeliveryResponseReadCommitAllowed(input: { + private async isOpenCodeDeliveryResponseReadCommitAllowed(input: { + teamName?: string; + memberName?: string; responseState?: NonNullable['state']; actionMode?: AgentActionMode; taskRefs?: TaskRef[]; visibleReply?: OpenCodeVisibleReplyProof | null; ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null; - }): boolean { + }): Promise { const state = input.responseState; if (!state || !isOpenCodePromptResponseStateResponded(state)) { return false; } + if (input.ledgerRecord?.messageKind === 'member_work_sync_nudge') { + return this.isOpenCodeMemberWorkSyncReadCommitAllowed({ + teamName: input.teamName, + memberName: input.memberName, + ledgerRecord: input.ledgerRecord, + }); + } if (state === 'responded_plain_text') { return this.isOpenCodePlainTextResponseReadCommitAllowed({ actionMode: input.actionMode, @@ -5556,18 +5577,12 @@ export class TeamProvisioningService { private hasOpenCodeNonVisibleProgressProof( ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null ): boolean { + if (ledgerRecord?.messageKind === 'member_work_sync_nudge') { + return this.hasOpenCodeMemberWorkSyncReadCommitProof(ledgerRecord); + } const toolNames = ledgerRecord?.observedToolCallNames ?? []; return toolNames.some((toolName) => { const normalized = this.normalizeOpenCodeObservedToolName(toolName); - if ( - ledgerRecord?.messageKind === 'member_work_sync_nudge' && - (normalized === 'member_work_sync_report' || - normalized === 'review_start' || - normalized === 'review_approve' || - normalized === 'review_request_changes') - ) { - return true; - } return ( normalized === 'task_start' || normalized === 'task_add_comment' || @@ -5584,6 +5599,97 @@ export class TeamProvisioningService { }); } + private hasOpenCodeMemberWorkSyncReportToolProof( + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null + ): boolean { + const toolNames = ledgerRecord?.observedToolCallNames ?? []; + return toolNames.some((toolName) => { + const normalized = this.normalizeOpenCodeObservedToolName(toolName); + return normalized === 'member_work_sync_report'; + }); + } + + private hasOpenCodeReviewPickupWorkflowProof( + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null + ): boolean { + if (ledgerRecord?.workSyncIntent !== 'review_pickup') { + return false; + } + const toolNames = ledgerRecord?.observedToolCallNames ?? []; + return toolNames.some((toolName) => { + const normalized = this.normalizeOpenCodeObservedToolName(toolName); + return ( + normalized === 'review_start' || + normalized === 'review_approve' || + normalized === 'review_request_changes' + ); + }); + } + + private hasOpenCodeMemberWorkSyncReadCommitProof( + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null + ): boolean { + return ( + this.hasOpenCodeMemberWorkSyncReportToolProof(ledgerRecord) || + this.hasOpenCodeReviewPickupWorkflowProof(ledgerRecord) + ); + } + + private async isOpenCodeMemberWorkSyncReadCommitAllowed(input: { + teamName?: string; + memberName?: string; + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null; + }): Promise { + if (this.hasOpenCodeReviewPickupWorkflowProof(input.ledgerRecord)) { + return true; + } + if (!this.hasOpenCodeMemberWorkSyncReportToolProof(input.ledgerRecord)) { + return false; + } + const teamName = input.teamName?.trim(); + const memberName = input.memberName?.trim(); + if (!teamName || !memberName) { + return false; + } + return this.hasAcceptedMemberWorkSyncReport({ teamName, memberName }); + } + + private async isLegacyOpenCodeMemberWorkSyncReadCommitAllowed(input: { + teamName: string; + memberName: string; + workSyncIntent?: OpenCodeTeamRuntimeMessageInput['workSyncIntent']; + responseObservation?: NonNullable; + }): Promise { + const state = input.responseObservation?.state; + if (!state || !isOpenCodePromptResponseStateResponded(state)) { + return false; + } + const toolNames = input.responseObservation?.toolCallNames ?? []; + const hasReviewPickupProof = + input.workSyncIntent === 'review_pickup' && + toolNames.some((toolName) => { + const normalized = this.normalizeOpenCodeObservedToolName(toolName); + return ( + normalized === 'review_start' || + normalized === 'review_approve' || + normalized === 'review_request_changes' + ); + }); + if (hasReviewPickupProof) { + return true; + } + const hasReportTool = toolNames.some( + (toolName) => this.normalizeOpenCodeObservedToolName(toolName) === 'member_work_sync_report' + ); + if (!hasReportTool) { + return false; + } + return this.hasAcceptedMemberWorkSyncReport({ + teamName: input.teamName, + memberName: input.memberName, + }); + } + private hasOpenCodeObservedMessageSendToolCall( ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null ): boolean { @@ -5636,6 +5742,22 @@ export class TeamProvisioningService { }): string { const record = input.ledgerRecord; const state = input.responseState ?? record?.responseState; + if (record?.messageKind === 'member_work_sync_nudge') { + if (state === 'responded_plain_text' || state === 'responded_visible_message') { + return 'member_work_sync_report_required'; + } + if (state === 'responded_non_visible_tool' || state === 'responded_tool_call') { + if (record.workSyncIntent !== 'review_pickup') { + return 'member_work_sync_report_required'; + } + if (!this.hasOpenCodeMemberWorkSyncReadCommitProof(record)) { + return 'member_work_sync_report_required'; + } + } + if (!this.hasOpenCodeMemberWorkSyncReadCommitProof(record)) { + return 'member_work_sync_report_required'; + } + } if (state === 'responded_visible_message' && !input.visibleReply) { return 'visible_reply_destination_not_found_yet'; } @@ -5760,7 +5882,17 @@ export class TeamProvisioningService { if ( input.ledgerRecord.lastReason === 'visible_reply_ack_only_still_requires_answer' || input.ledgerRecord.lastReason === 'plain_text_ack_only_still_requires_answer' || - input.ledgerRecord.lastReason === 'visible_reply_missing_task_refs' + input.ledgerRecord.lastReason === 'visible_reply_missing_task_refs' || + input.ledgerRecord.lastReason === 'member_work_sync_report_required' + ) { + return true; + } + if ( + input.ledgerRecord.messageKind === 'member_work_sync_nudge' && + (input.ledgerRecord.responseState === 'responded_visible_message' || + input.ledgerRecord.responseState === 'responded_plain_text' || + input.ledgerRecord.responseState === 'responded_non_visible_tool' || + input.ledgerRecord.responseState === 'responded_tool_call') ) { return true; } @@ -6635,7 +6767,9 @@ export class TeamProvisioningService { let ledgerRecord = input.ledgerRecord; let visibleReply = input.visibleReply ?? null; const observeMessageDelivery = input.adapter.observeMessageDelivery; - const readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ + const readAllowed = await this.isOpenCodeDeliveryResponseReadCommitAllowed({ + teamName: input.teamName, + memberName: input.memberName, responseState: ledgerRecord.responseState, actionMode: ledgerRecord.actionMode ?? undefined, taskRefs: ledgerRecord.taskRefs, @@ -6784,7 +6918,9 @@ export class TeamProvisioningService { }); ledgerRecord = materialized.ledgerRecord; visibleReply = materialized.visibleReply; - const observedReadAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ + const observedReadAllowed = await this.isOpenCodeDeliveryResponseReadCommitAllowed({ + teamName: input.teamName, + memberName: input.memberName, responseState: ledgerRecord.responseState, actionMode: ledgerRecord.actionMode ?? undefined, taskRefs: ledgerRecord.taskRefs, @@ -8752,14 +8888,27 @@ export class TeamProvisioningService { teamColor: config?.color, teamDisplayName: config?.name, }); + const legacyWorkSyncReadAllowed = + input.messageKind === 'member_work_sync_nudge' && result.ok + ? await this.isLegacyOpenCodeMemberWorkSyncReadCommitAllowed({ + teamName, + memberName: canonicalMemberName, + workSyncIntent: input.workSyncIntent, + responseObservation, + }) + : true; + const legacyWorkSyncResponsePending = + result.ok && input.messageKind === 'member_work_sync_nudge' && !legacyWorkSyncReadAllowed; return { delivered: result.ok, accepted: result.ok, - responsePending: false, + responsePending: legacyWorkSyncResponsePending, responseState: responseObservation?.state, - ...(result.ok - ? {} - : { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }), + ...(legacyWorkSyncResponsePending + ? { reason: responseObservation?.reason ?? 'member_work_sync_report_required' } + : result.ok + ? {} + : { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }), diagnostics: result.diagnostics, }; } @@ -8794,7 +8943,9 @@ export class TeamProvisioningService { visibleReply: proof.visibleReply, }); active = proof.ledgerRecord; - const activeReadAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ + const activeReadAllowed = await this.isOpenCodeDeliveryResponseReadCommitAllowed({ + teamName, + memberName: canonicalMemberName, responseState: active.responseState, actionMode: active.actionMode ?? undefined, taskRefs: active.taskRefs, @@ -8882,7 +9033,9 @@ export class TeamProvisioningService { visibleReply: proof.visibleReply, }); ledgerRecord = proof.ledgerRecord; - let readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ + let readAllowed = await this.isOpenCodeDeliveryResponseReadCommitAllowed({ + teamName, + memberName: canonicalMemberName, responseState: ledgerRecord.responseState, actionMode: ledgerRecord.actionMode ?? undefined, taskRefs: ledgerRecord.taskRefs, @@ -9071,7 +9224,9 @@ export class TeamProvisioningService { visibleReply: proof.visibleReply, }); ledgerRecord = proof.ledgerRecord; - readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ + readAllowed = await this.isOpenCodeDeliveryResponseReadCommitAllowed({ + teamName, + memberName: canonicalMemberName, responseState: ledgerRecord.responseState, actionMode: ledgerRecord.actionMode ?? undefined, taskRefs: ledgerRecord.taskRefs, @@ -9199,7 +9354,9 @@ export class TeamProvisioningService { } const retryReadAllowed = ledgerRecord - ? this.isOpenCodeDeliveryResponseReadCommitAllowed({ + ? await this.isOpenCodeDeliveryResponseReadCommitAllowed({ + teamName, + memberName: canonicalMemberName, responseState: ledgerRecord.responseState, actionMode: ledgerRecord.actionMode ?? undefined, taskRefs: ledgerRecord.taskRefs, @@ -9463,7 +9620,9 @@ export class TeamProvisioningService { ledgerRecord.visibleReplyCorrelation === 'relayOfMessageId', }) : null; - const readAllowed = this.isOpenCodeDeliveryResponseReadCommitAllowed({ + const readAllowed = await this.isOpenCodeDeliveryResponseReadCommitAllowed({ + teamName, + memberName: canonicalMemberName, responseState, actionMode: input.actionMode, taskRefs: input.taskRefs, @@ -23471,15 +23630,17 @@ export class TeamProvisioningService { recoveredVisibleReply = null; } } - const recoveredReadAllowed = - recoveredRecord && - this.isOpenCodeDeliveryResponseReadCommitAllowed({ - responseState: recoveredRecord.responseState, - actionMode: recoveredRecord.actionMode ?? undefined, - taskRefs: recoveredRecord.taskRefs, - visibleReply: recoveredVisibleReply, - ledgerRecord: recoveredRecord, - }); + const recoveredReadAllowed = recoveredRecord + ? await this.isOpenCodeDeliveryResponseReadCommitAllowed({ + teamName, + memberName: memberIdentity.canonicalMemberName, + responseState: recoveredRecord.responseState, + actionMode: recoveredRecord.actionMode ?? undefined, + taskRefs: recoveredRecord.taskRefs, + visibleReply: recoveredVisibleReply, + ledgerRecord: recoveredRecord, + }) + : false; if (recoveredRecord && recoveredReadAllowed) { try { await this.markInboxMessagesRead(teamName, memberName, [message]); @@ -23967,6 +24128,101 @@ export class TeamProvisioningService { return from === 'user' || message.source === 'user_sent'; } + private async hasAcceptedMemberWorkSyncReport(input: { + teamName: string; + memberName: string; + }): Promise { + const checker = this.memberWorkSyncAcceptedReportChecker; + if (!checker) { + return false; + } + + try { + return ( + (await checker({ + teamName: input.teamName, + memberName: input.memberName, + })) === true + ); + } catch (error) { + logger.warn( + `[${input.teamName}] Failed to check accepted work sync report for ${input.memberName}: ${getErrorMessage(error)}` + ); + return false; + } + } + + private async hasAcceptedLeadWorkSyncReport(input: { + teamName: string; + leadName: string; + }): Promise { + return this.hasAcceptedMemberWorkSyncReport({ + teamName: input.teamName, + memberName: input.leadName, + }); + } + + private async scheduleLeadProofMissingWorkSyncRecovery(input: { + teamName: string; + leadName: string; + message: InboxMessage & { messageId: string }; + }): Promise { + const scheduler = this.memberWorkSyncProofMissingRecoveryScheduler; + if (!scheduler) { + return false; + } + + try { + const result = (await scheduler({ + teamName: input.teamName, + memberName: input.leadName, + originalMessageId: input.message.messageId, + taskRefs: input.message.taskRefs, + reason: 'lead_member_work_sync_report_required', + })) as { scheduled?: boolean; reason?: string } | null | undefined; + return result?.scheduled === true || result?.reason === 'coalesced_recent'; + } catch (error) { + logger.warn( + `[${input.teamName}] Failed to schedule lead proof-missing work sync recovery for ${input.leadName}: ${getErrorMessage(error)}` + ); + return false; + } + } + + private async getLeadRelayReadCommitBatch(input: { + teamName: string; + leadName: string; + batch: (InboxMessage & { messageId: string })[]; + }): Promise<(InboxMessage & { messageId: string })[]> { + const readCommitBatch: (InboxMessage & { messageId: string })[] = []; + for (const message of input.batch) { + if (message.messageKind !== 'member_work_sync_nudge') { + readCommitBatch.push(message); + continue; + } + + if ( + await this.hasAcceptedLeadWorkSyncReport({ + teamName: input.teamName, + leadName: input.leadName, + }) + ) { + readCommitBatch.push(message); + continue; + } + + const recoveryScheduled = await this.scheduleLeadProofMissingWorkSyncRecovery({ + teamName: input.teamName, + leadName: input.leadName, + message, + }); + if (recoveryScheduled) { + readCommitBatch.push(message); + } + } + return readCommitBatch; + } + async relayLeadInboxMessages(teamName: string): Promise { const existing = this.leadInboxRelayInFlight.get(teamName); if (existing) { @@ -24407,10 +24663,6 @@ export class TeamProvisioningService { return 0; } - for (const m of batch) { - relayedIds.add(m.messageId); - } - this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds)); this.rememberRecentCrossTeamLeadDeliveryMessageIds( teamName, batch @@ -24418,12 +24670,6 @@ export class TeamProvisioningService { .map((message) => message.messageId) ); - try { - await this.markInboxMessagesRead(teamName, leadName, batch); - } catch { - // Best-effort: relay succeeded; marking read failed. - } - let replyText: string | null = null; let capturedVisibleSendMessage = false; let capturedUserVisibleSendMessage = false; @@ -24448,6 +24694,23 @@ export class TeamProvisioningService { } } + const readCommitBatch = await this.getLeadRelayReadCommitBatch({ + teamName, + leadName, + batch, + }); + for (const m of readCommitBatch) { + relayedIds.add(m.messageId); + } + this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds)); + if (readCommitBatch.length > 0) { + try { + await this.markInboxMessagesRead(teamName, leadName, readCommitBatch); + } catch { + // Best-effort: relay succeeded; marking read failed. + } + } + // Strip agent-only blocks — lead may respond with pure coordination content // that is not meant for the human user. const cleanReply = replyText diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index e986e5c3..850d0ee6 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -1126,6 +1126,7 @@ function buildMemberBootstrapPrompt( 'This OpenCode session is created, attached, and launch-verified by the desktop app.', 'Do not call runtime_bootstrap_checkin or member_briefing just to prove launch readiness.', 'Do NOT create local team files, run join scripts, or search the project for a fake team registry.', + 'That bootstrap restriction is only about team registry/startup files. It does not restrict assigned project work: when a task requires implementation, fixes, review follow-up, or investigation, you may inspect, read/search, and edit files in the project working directory as your available tools allow.', 'Use the app MCP tools exposed by the "agent-teams" server for team communication and task state.', 'Launch bootstrap is a silent attach, not a user/team conversation turn.', 'Do not call task_briefing, message_send, or cross_team_send just to announce readiness, say understood, report no tasks, or ask for work.', @@ -1190,6 +1191,12 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) input.taskRefs ?.map((ref) => ref.taskId?.trim()) .filter((taskId): taskId is string => Boolean(taskId)) ?? []; + const actionModeWorkScopeReminder = + input.actionMode === 'ask' + ? 'Action mode ASK is read-only for this delivered message: do not edit files, change task state, or run side-effecting tools for this message.' + : input.actionMode === 'delegate' + ? 'Action mode DELEGATE is orchestration-only for this delivered message: pass the task with context instead of implementing or editing files yourself.' + : 'If this delivered message assigns implementation, fixes, review follow-up, or concrete investigation, you may inspect, read/search, and edit files in the project working directory as your available tools allow.'; // Work-sync nudges are health/reporting probes. Requiring a visible // message_send reply here causes false delivery failures, so accept the // dedicated member_work_sync_report proof path while keeping normal user @@ -1199,7 +1206,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) 'This delivered app message is a targeted member-work-sync review pickup nudge.', 'Process the current review request now if it is still assigned to you. Open the task, verify reviewState/status, then use the review workflow tools to start or continue the review.', 'Do not mark the review complete from this prompt alone.', - 'A visible agent-teams_message_send reply is optional. Concrete review progress, review tool usage, or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.', + 'A visible agent-teams_message_send reply is optional. Review workflow tool usage or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.', `If you cannot pick up the review now, call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}, then report state "blocked" or "still_working" only for the real current state.`, 'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.', taskIds.length ? `Relevant taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` : null, @@ -1209,7 +1216,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) : isWorkSyncNudge ? [ 'This delivered app message is a member-work-sync nudge.', - 'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.', + 'A visible agent-teams_message_send reply is optional. For agenda sync, only agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.', `Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}.`, `Then call agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) with ${workSyncToolArgs}, the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`, 'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.', @@ -1241,6 +1248,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) ? `${deliveryContext}` : null, 'You are running in OpenCode, not Claude Code or Codex native.', + actionModeWorkScopeReminder, ...responseInstructions, 'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.', 'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.', diff --git a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts index a46a06b5..811661d8 100644 --- a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts +++ b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts @@ -413,6 +413,7 @@ describe('team model availability Codex catalog integration', () => { expect(getAvailableTeamProviderModels('anthropic', providerStatus)).toEqual([ 'haiku', 'opus', + 'claude-opus-4-7', 'claude-opus-4-6', 'sonnet', ]); @@ -431,6 +432,13 @@ describe('team model availability Codex catalog integration', () => { availabilityStatus: 'available', availabilityReason: null, }, + { + value: 'claude-opus-4-7', + label: 'Opus 4.7', + badgeLabel: 'Opus 4.7', + availabilityStatus: 'available', + availabilityReason: null, + }, { value: 'claude-opus-4-6', label: 'Opus 4.6', diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index 6aa4a231..8ef43620 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -166,12 +166,7 @@ export function isTeamProviderModelVerificationPending( return true; } - const verificationState = providerStatus.verificationState as - | 'verified' - | 'unknown' - | 'offline' - | 'error' - | undefined; + const verificationState = providerStatus.verificationState; if (verificationState === 'error' || providerStatus.modelCatalogRefreshState === 'error') { return false; } diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 6b65a513..92b983a7 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -232,14 +232,16 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort { async markDelivered(input: MemberWorkSyncOutboxMarkDeliveredInput): Promise { const current = this.items.get(input.id); if (current?.attemptGeneration === input.attemptGeneration) { - this.items.set(input.id, { + const next = { ...current, - status: 'delivered', + status: 'delivered' as const, deliveredMessageId: input.deliveredMessageId, ...(input.deliveryState ? { deliveryState: input.deliveryState } : {}), ...(input.deliveryDiagnostics ? { deliveryDiagnostics: input.deliveryDiagnostics } : {}), updatedAt: input.nowIso, - }); + }; + delete next.nextAttemptAt; + this.items.set(input.id, next); } } @@ -263,12 +265,18 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort { } } - async countRecentDelivered(input: { memberName: string; sinceIso: string }): Promise { + async countRecentDelivered(input: { + memberName: string; + sinceIso: string; + workSyncIntentKeyPrefix?: string; + }): Promise { return [...this.items.values()].filter( (item) => item.status === 'delivered' && item.memberName === input.memberName && - item.updatedAt >= input.sinceIso + item.updatedAt >= input.sinceIso && + (!input.workSyncIntentKeyPrefix || + item.payload.workSyncIntentKey?.startsWith(input.workSyncIntentKeyPrefix) === true) ).length; } @@ -296,17 +304,22 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort { class InMemoryInboxNudge implements MemberWorkSyncInboxNudgePort { readonly inserted: Array[0]> = []; fail = false; + conflict = false; async insertIfAbsent(input: Parameters[0]) { if (this.fail) { throw new Error('inbox unavailable'); } + if (this.conflict) { + return { inserted: false, messageId: input.messageId, conflict: true }; + } this.inserted.push(input); return { inserted: true, messageId: input.messageId }; } } function createDeps(options?: { + memberName?: string; items?: MemberWorkSyncActionableWorkItem[]; activeMemberNames?: string[]; inactive?: boolean; @@ -321,15 +334,16 @@ function createDeps(options?: { const clock = new MutableClock(); const store = new InMemoryStatusStore(); const auditEvents: MemberWorkSyncAuditEvent[] = []; + const memberName = options?.memberName ?? 'bob'; const source: MemberWorkSyncAgendaSourceResult = { agenda: { teamName: 'team-a', - memberName: 'bob', + memberName, generatedAt: '2026-04-29T00:00:00.000Z', items: options?.items ?? [workItem], diagnostics: [], }, - activeMemberNames: options?.activeMemberNames ?? ['bob'], + activeMemberNames: options?.activeMemberNames ?? [memberName], inactive: options?.inactive ?? false, ...(options?.providerId ? { providerId: options.providerId } : {}), diagnostics: [], @@ -940,6 +954,147 @@ describe('MemberWorkSync use cases', () => { expect(inbox.inserted[1]?.messageId).toContain('status-only'); }); + it('creates a delivered-still-stuck recovery after a delivered status-only nudge gets no report', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const { clock, deps, store } = createDeps({ + providerId: 'codex', + outboxStore: outbox, + inboxNudge: inbox, + }); + store.phase2ReadinessState = 'shadow_ready'; + + const reconciler = new MemberWorkSyncReconciler(deps); + const firstStatus = await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['turn_settled'] } + ); + await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(inbox.inserted).toHaveLength(2); + expect(inbox.inserted[1]?.messageId).toContain('status-only'); + + clock.set('2026-04-29T00:10:00.000Z'); + store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z'; + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['turn_settled'] } + ); + + const stillStuck = [...outbox.items.values()].find((item) => + item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:') + ); + expect(stillStuck).toMatchObject({ + status: 'pending', + agendaFingerprint: firstStatus.agenda.fingerprint, + }); + + const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(summary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 }); + expect(inbox.inserted).toHaveLength(3); + expect(inbox.inserted[2]?.messageId).toContain('agenda-sync-still-stuck'); + }); + + it('creates a still-stuck recovery when a terminal inbox conflict blocks a status-only nudge', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const { clock, deps, store } = createDeps({ + providerId: 'codex', + outboxStore: outbox, + inboxNudge: inbox, + }); + store.phase2ReadinessState = 'shadow_ready'; + + const reconciler = new MemberWorkSyncReconciler(deps); + const firstStatus = await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['turn_settled'] } + ); + + inbox.conflict = true; + const terminalSummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + const statusOnly = [...outbox.items.values()].find((item) => + item.payload.workSyncIntentKey?.startsWith('status-only:') + ); + expect(terminalSummary).toMatchObject({ claimed: 1, delivered: 0, terminal: 1 }); + expect(statusOnly).toMatchObject({ + status: 'failed_terminal', + lastError: 'inbox_payload_conflict', + }); + + inbox.conflict = false; + clock.set('2026-04-29T00:10:00.000Z'); + store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z'; + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['turn_settled'] } + ); + + const stillStuck = [...outbox.items.values()].find((item) => + item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:') + ); + expect(stillStuck).toMatchObject({ + status: 'pending', + agendaFingerprint: firstStatus.agenda.fingerprint, + }); + + const recoverySummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(recoverySummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 }); + expect(inbox.inserted).toHaveLength(2); + expect(inbox.inserted[1]?.messageId).toContain('agenda-sync-still-stuck'); + }); + it('creates an agenda-sync refresh recovery when a delivered nudge has a stale payload hash', async () => { const outbox = new InMemoryOutboxStore(); const inbox = new InMemoryInboxNudge(); @@ -1040,6 +1195,460 @@ describe('MemberWorkSync use cases', () => { expect(statusOnlyItems[0]?.payload.text).toContain('Status-only recovery'); }); + it('creates a delivered-still-stuck recovery after a delivered refresh nudge gets no report', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + outbox.rejectPayloadConflicts = true; + const { clock, deps, store } = createDeps({ + providerId: 'codex', + outboxStore: outbox, + inboxNudge: inbox, + }); + store.phase2ReadinessState = 'shadow_ready'; + + const reconciler = new MemberWorkSyncReconciler(deps); + const firstStatus = await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + const baseId = `member-work-sync:team-a:bob:${firstStatus.agenda.fingerprint}`; + const delivered = outbox.items.get(baseId); + expect(delivered).toMatchObject({ status: 'delivered' }); + outbox.items.set(baseId, { + ...delivered!, + payloadHash: 'legacy-payload-hash', + payload: { + ...delivered!.payload, + text: 'Legacy delivered work-sync nudge text.', + }, + }); + + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect( + [...outbox.items.values()].filter((item) => + item.payload.workSyncIntentKey?.startsWith('agenda-sync-refresh:') + ) + ).toHaveLength(1); + expect(inbox.inserted).toHaveLength(2); + + clock.set('2026-04-29T00:10:00.000Z'); + store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z'; + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['manual_refresh'] } + ); + + const stillStuck = [...outbox.items.values()].find((item) => + item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:') + ); + expect(stillStuck).toMatchObject({ + status: 'pending', + agendaFingerprint: firstStatus.agenda.fingerprint, + }); + + const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(summary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 }); + expect(inbox.inserted).toHaveLength(3); + expect(inbox.inserted[2]?.messageId).toContain('agenda-sync-still-stuck'); + }); + + it('creates a delivered-still-stuck recovery when a delivered agenda nudge gets no report', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const { clock, deps, store } = createDeps({ + providerId: 'codex', + outboxStore: outbox, + inboxNudge: inbox, + }); + store.phase2ReadinessState = 'shadow_ready'; + + const reconciler = new MemberWorkSyncReconciler(deps); + const firstStatus = await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + const baseId = `member-work-sync:team-a:bob:${firstStatus.agenda.fingerprint}`; + expect(outbox.items.get(baseId)).toMatchObject({ status: 'delivered' }); + + clock.set('2026-04-29T00:10:00.000Z'); + store.phase2ReadinessState = 'blocked'; + store.phase2ReadinessReasons = ['would_nudge_rate_high']; + store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z'; + store.recentEvents = [ + { + id: 'stale-current-needs-sync', + teamName: 'team-a', + memberName: 'bob', + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: firstStatus.agenda.fingerprint, + recordedAt: '2026-04-29T00:02:00.000Z', + actionableCount: 1, + providerId: 'codex', + }, + ]; + + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['manual_refresh'] } + ); + + const recovery = [...outbox.items.values()].find((item) => + item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:') + ); + expect(recovery).toMatchObject({ + status: 'pending', + agendaFingerprint: firstStatus.agenda.fingerprint, + payload: { + workSyncIntent: 'agenda_sync', + workSyncIntentKey: expect.stringContaining( + `agenda-sync-still-stuck:${firstStatus.agenda.fingerprint}:` + ), + }, + }); + expect(recovery?.payload.text).toContain('still no accepted member_work_sync_report'); + expect(outbox.items.get(baseId)).toMatchObject({ status: 'delivered' }); + + const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(summary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 }); + expect(inbox.inserted).toHaveLength(2); + expect(inbox.inserted[1]?.messageId).toContain('agenda-sync-still-stuck'); + + clock.set('2026-04-29T00:20:00.000Z'); + store.metricsGeneratedAt = '2026-04-29T00:20:00.000Z'; + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['manual_refresh'] } + ); + + expect( + [...outbox.items.values()].filter((item) => + item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:') + ) + ).toHaveLength(1); + + clock.set('2026-04-29T01:02:00.000Z'); + store.metricsGeneratedAt = '2026-04-29T01:02:00.000Z'; + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['manual_refresh'] } + ); + + const recoveryItems = [...outbox.items.values()].filter((item) => + item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:') + ); + expect(recoveryItems).toHaveLength(2); + expect(new Set(recoveryItems.map((item) => item.id)).size).toBe(2); + + const secondSummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(secondSummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 }); + expect(inbox.inserted).toHaveLength(3); + }); + + it('records an existing delivered agenda nudge as skipped before still-stuck recovery age', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const { auditEvents, clock, deps, store } = createDeps({ + providerId: 'codex', + outboxStore: outbox, + inboxNudge: inbox, + }); + store.phase2ReadinessState = 'shadow_ready'; + + const reconciler = new MemberWorkSyncReconciler(deps); + const firstStatus = await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + const baseId = `member-work-sync:team-a:bob:${firstStatus.agenda.fingerprint}`; + expect(outbox.items.get(baseId)).toMatchObject({ status: 'delivered' }); + + clock.set('2026-04-29T00:04:00.000Z'); + store.metricsGeneratedAt = '2026-04-29T00:04:00.000Z'; + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['manual_refresh'] } + ); + + expect( + [...outbox.items.values()].filter((item) => + item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:') + ) + ).toHaveLength(0); + expect(auditEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: 'nudge_skipped', + reason: 'existing', + }), + ]) + ); + }); + + it('creates a delivered-still-stuck recovery for a targeted lead despite noisy metrics', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const leadWorkItem: MemberWorkSyncActionableWorkItem = { + ...workItem, + assignee: 'team-lead', + evidence: { + status: 'pending', + owner: 'team-lead', + }, + }; + const { clock, deps, store } = createDeps({ + memberName: 'team-lead', + items: [leadWorkItem], + providerId: 'codex', + outboxStore: outbox, + inboxNudge: inbox, + }); + store.phase2ReadinessState = 'blocked'; + store.phase2ReadinessReasons = ['would_nudge_rate_high']; + + const reconciler = new MemberWorkSyncReconciler(deps); + const firstStatus = await reconciler.execute( + { + teamName: 'team-a', + memberName: 'team-lead', + }, + { reconciledBy: 'queue', triggerReasons: ['manual_refresh'] } + ); + await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + const baseId = `member-work-sync:team-a:team-lead:${firstStatus.agenda.fingerprint}`; + expect(outbox.items.get(baseId)).toMatchObject({ status: 'delivered' }); + + clock.set('2026-04-29T00:10:00.000Z'); + store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z'; + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'team-lead', + }, + { reconciledBy: 'queue', triggerReasons: ['manual_refresh'] } + ); + + const recovery = [...outbox.items.values()].find((item) => + item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:') + ); + expect(recovery).toMatchObject({ + status: 'pending', + memberName: 'team-lead', + agendaFingerprint: firstStatus.agenda.fingerprint, + }); + + const recoverySummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(recoverySummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 }); + expect(inbox.inserted).toHaveLength(2); + expect(inbox.inserted[1]?.messageId).toContain('agenda-sync-still-stuck'); + }); + + it('creates a still-stuck recovery when a terminal inbox conflict blocks an agenda nudge', async () => { + const outbox = new InMemoryOutboxStore(); + const inbox = new InMemoryInboxNudge(); + const { clock, deps, store } = createDeps({ + providerId: 'codex', + outboxStore: outbox, + inboxNudge: inbox, + }); + store.phase2ReadinessState = 'shadow_ready'; + + const reconciler = new MemberWorkSyncReconciler(deps); + const firstStatus = await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + const baseId = `member-work-sync:team-a:bob:${firstStatus.agenda.fingerprint}`; + expect(outbox.items.get(baseId)).toMatchObject({ status: 'pending' }); + + inbox.conflict = true; + const terminalSummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(terminalSummary).toMatchObject({ claimed: 1, delivered: 0, terminal: 1 }); + expect(outbox.items.get(baseId)).toMatchObject({ + status: 'failed_terminal', + lastError: 'inbox_payload_conflict', + }); + + inbox.conflict = false; + clock.set('2026-04-29T00:10:00.000Z'); + store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z'; + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['manual_refresh'] } + ); + + const recovery = [...outbox.items.values()].find((item) => + item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:') + ); + expect(recovery).toMatchObject({ + status: 'pending', + agendaFingerprint: firstStatus.agenda.fingerprint, + payload: { + workSyncIntent: 'agenda_sync', + workSyncIntentKey: expect.stringContaining( + `agenda-sync-still-stuck:${firstStatus.agenda.fingerprint}:` + ), + }, + }); + expect(recovery?.payload.text).toContain('still no accepted member_work_sync_report'); + expect(outbox.items.get(baseId)).toMatchObject({ status: 'failed_terminal' }); + + const recoverySummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(recoverySummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 }); + expect(inbox.inserted).toHaveLength(1); + expect(inbox.inserted[0]?.messageId).toContain('agenda-sync-still-stuck'); + }); + + it('creates a still-stuck recovery when a terminal inbox conflict has a stale payload hash', async () => { + const outbox = new InMemoryOutboxStore(); + outbox.rejectPayloadConflicts = true; + const inbox = new InMemoryInboxNudge(); + const { clock, deps, store } = createDeps({ + providerId: 'codex', + outboxStore: outbox, + inboxNudge: inbox, + }); + store.phase2ReadinessState = 'shadow_ready'; + + const reconciler = new MemberWorkSyncReconciler(deps); + const firstStatus = await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['task_changed'] } + ); + const baseId = `member-work-sync:team-a:bob:${firstStatus.agenda.fingerprint}`; + + inbox.conflict = true; + await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + const terminal = outbox.items.get(baseId); + expect(terminal).toMatchObject({ + status: 'failed_terminal', + lastError: 'inbox_payload_conflict', + }); + outbox.items.set(baseId, { + ...terminal!, + payloadHash: 'stale-terminal-payload-hash', + }); + + inbox.conflict = false; + clock.set('2026-04-29T00:10:00.000Z'); + store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z'; + await reconciler.execute( + { + teamName: 'team-a', + memberName: 'bob', + }, + { reconciledBy: 'queue', triggerReasons: ['manual_refresh'] } + ); + + const recovery = [...outbox.items.values()].find((item) => + item.payload.workSyncIntentKey?.startsWith('agenda-sync-still-stuck:') + ); + expect(recovery).toMatchObject({ + status: 'pending', + agendaFingerprint: firstStatus.agenda.fingerprint, + }); + + const recoverySummary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({ + teamNames: ['team-a'], + claimedBy: 'test-dispatcher', + }); + + expect(recoverySummary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 }); + expect(inbox.inserted).toHaveLength(1); + expect(inbox.inserted[0]?.messageId).toContain('agenda-sync-still-stuck'); + }); + it('marks review pickup delivered only after the delivery port confirms prompt acceptance', async () => { const outbox = new InMemoryOutboxStore(); const inbox = new InMemoryInboxNudge(); diff --git a/test/features/member-work-sync/main/CompositeMemberWorkSyncBusySignal.test.ts b/test/features/member-work-sync/main/CompositeMemberWorkSyncBusySignal.test.ts index 6521a585..934685e0 100644 --- a/test/features/member-work-sync/main/CompositeMemberWorkSyncBusySignal.test.ts +++ b/test/features/member-work-sync/main/CompositeMemberWorkSyncBusySignal.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; describe('CompositeMemberWorkSyncBusySignal', () => { it('does not block nudges forever when one busy signal fails', async () => { - const logger = { warn: vi.fn() }; + const logger = { debug: vi.fn(), error: vi.fn(), warn: vi.fn() }; const signal = new CompositeMemberWorkSyncBusySignal( [ { @@ -24,7 +24,7 @@ describe('CompositeMemberWorkSyncBusySignal', () => { memberName: 'bob', nowIso: '2026-04-29T00:00:00.000Z', workSyncIntent: 'agenda_sync', - taskRefs: [{ teamName: 'team-a', taskId: 'task-1' }], + taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }], }) ).resolves.toEqual({ busy: false }); expect(logger.warn).toHaveBeenCalledWith( @@ -56,7 +56,7 @@ describe('CompositeMemberWorkSyncBusySignal', () => { memberName: 'bob', nowIso: '2026-04-29T00:00:00.000Z', workSyncIntent: 'agenda_sync', - taskRefs: [{ teamName: 'team-a', taskId: 'task-1' }], + taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }], }) ).resolves.toEqual({ busy: true, diff --git a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts index 09f02e4b..87553f82 100644 --- a/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts +++ b/test/features/member-work-sync/main/JsonMemberWorkSyncStore.test.ts @@ -352,7 +352,7 @@ describe('JsonMemberWorkSyncStore', () => { }); }); - it('deduplicates outbox items by id and rejects payload hash conflicts', async () => { + it('refreshes undelivered outbox payloads but rejects delivered payload conflicts', async () => { const input = { id: 'member-work-sync:team-a:bob:agenda:v1:abc', teamName: 'team-a', @@ -372,11 +372,97 @@ describe('JsonMemberWorkSyncStore', () => { ok: true, outcome: 'existing', }); - await expect(store.ensurePending({ ...input, payloadHash: 'hash-b' })).resolves.toMatchObject({ + const refreshed = await store.ensurePending({ + ...input, + payloadHash: 'hash-b', + payload: makeNudgePayload({ + text: 'Work sync check: call member_work_sync_status and member_work_sync_report.', + }), + nowIso: '2026-04-29T00:01:00.000Z', + }); + expect(refreshed).toMatchObject({ + ok: true, + outcome: 'existing', + item: { + status: 'pending', + payloadHash: 'hash-b', + payload: { + text: 'Work sync check: call member_work_sync_status and member_work_sync_report.', + }, + }, + }); + + const [claimed] = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:02:00.000Z', + limit: 1, + }); + const claimedRefresh = await store.ensurePending({ + ...input, + payloadHash: 'hash-c', + payload: makeNudgePayload({ text: 'New text while delivery is claimed.' }), + nowIso: '2026-04-29T00:02:30.000Z', + }); + expect(claimedRefresh).toMatchObject({ + ok: true, + outcome: 'existing', + item: { + status: 'pending', + payloadHash: 'hash-c', + payload: { text: 'New text while delivery is claimed.' }, + attemptGeneration: claimed.attemptGeneration + 1, + }, + }); + await store.markDelivered({ + teamName: 'team-a', + id: input.id, + attemptGeneration: claimed.attemptGeneration, + deliveredMessageId: 'message-1', + nowIso: '2026-04-29T00:03:00.000Z', + }); + const afterStaleDelivery = JSON.parse( + await readFile( + join(root, 'team-a', 'members', 'bob', '.member-work-sync', 'outbox.json'), + 'utf8' + ) + ); + expect(afterStaleDelivery.items[input.id]).toMatchObject({ + status: 'pending', + payloadHash: 'hash-c', + }); + + const [reclaimed] = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-b', + nowIso: '2026-04-29T00:03:30.000Z', + limit: 1, + }); + expect(reclaimed).toMatchObject({ + id: input.id, + payloadHash: 'hash-c', + attemptGeneration: claimed.attemptGeneration + 2, + }); + await store.markDelivered({ + teamName: 'team-a', + id: input.id, + attemptGeneration: reclaimed.attemptGeneration, + deliveredMessageId: 'message-2', + nowIso: '2026-04-29T00:03:45.000Z', + }); + + await expect( + store.ensurePending({ + ...input, + payloadHash: 'hash-d', + payload: makeNudgePayload({ text: 'New text after delivery.' }), + nowIso: '2026-04-29T00:04:00.000Z', + }) + ).resolves.toMatchObject({ ok: false, outcome: 'payload_conflict', - existingPayloadHash: 'hash-a', - requestedPayloadHash: 'hash-b', + existingPayloadHash: 'hash-c', + requestedPayloadHash: 'hash-d', }); }); @@ -525,6 +611,67 @@ describe('JsonMemberWorkSyncStore', () => { ).resolves.toEqual([]); }); + it('clears retry delay when a retryable outbox item is delivered', async () => { + const input = { + id: 'member-work-sync:team-a:bob:agenda:v1:abc', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + payloadHash: 'hash-a', + payload: makeNudgePayload(), + nowIso: '2026-04-29T00:00:00.000Z', + }; + + await store.ensurePending(input); + const [claimed] = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:01:00.000Z', + limit: 1, + }); + await store.markFailed({ + teamName: 'team-a', + id: input.id, + attemptGeneration: claimed.attemptGeneration, + retryable: true, + error: 'member_busy:active_tool_activity', + nextAttemptAt: '2026-04-29T00:30:00.000Z', + nowIso: '2026-04-29T00:02:00.000Z', + }); + + const [reclaimed] = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-b', + nowIso: '2026-04-29T00:30:00.000Z', + limit: 1, + }); + await store.markDelivered({ + teamName: 'team-a', + id: input.id, + attemptGeneration: reclaimed.attemptGeneration, + deliveredMessageId: 'message-1', + nowIso: '2026-04-29T00:31:00.000Z', + }); + + const memberOutbox = JSON.parse( + await readFile( + join(root, 'team-a', 'members', 'bob', '.member-work-sync', 'outbox.json'), + 'utf8' + ) + ); + expect(memberOutbox.items[input.id]).toMatchObject({ status: 'delivered' }); + expect(memberOutbox.items[input.id]).not.toHaveProperty('nextAttemptAt'); + + const index = JSON.parse( + await readFile( + join(root, 'team-a', '.member-work-sync', 'indexes', 'outbox-index.json'), + 'utf8' + ) + ); + expect(index.items[input.id]).toMatchObject({ status: 'delivered' }); + expect(index.items[input.id]).not.toHaveProperty('nextAttemptAt'); + }); + it('finds recent recovery outbox rows by logical intent key', async () => { const olderInput = { id: 'member-work-sync:team-a:bob:agenda:v1:older', @@ -827,6 +974,67 @@ describe('JsonMemberWorkSyncStore', () => { expect(repaired.items[bobInput.id]).toMatchObject({ memberName: 'bob', status: 'delivered' }); }); + it('filters recent delivered counts by work sync intent key prefix when requested', async () => { + const baseInput = { + id: 'member-work-sync:team-a:bob:agenda:v1:abc', + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + payloadHash: 'hash-a', + payload: makeNudgePayload(), + nowIso: '2026-04-29T00:00:00.000Z', + }; + const stillStuckInput = { + ...baseInput, + id: 'member-work-sync:team-a:bob:agenda-sync-still-stuck:agenda:v1:abc:hash-a:bucket', + payloadHash: 'hash-still-stuck', + payload: makeNudgePayload({ + workSyncIntentKey: 'agenda-sync-still-stuck:agenda:v1:abc:hash-a:bucket', + }), + }; + const statusOnlyInput = { + ...baseInput, + id: 'member-work-sync:team-a:bob:status-only:agenda:v1:abc', + payloadHash: 'hash-status-only', + payload: makeNudgePayload({ workSyncIntentKey: 'status-only:agenda:v1:abc' }), + }; + await store.ensurePending(baseInput); + await store.ensurePending(stillStuckInput); + await store.ensurePending(statusOnlyInput); + + const claimed = await store.claimDue({ + teamName: 'team-a', + claimedBy: 'dispatcher-a', + nowIso: '2026-04-29T00:01:00.000Z', + limit: 3, + }); + for (const item of claimed) { + await store.markDelivered({ + teamName: 'team-a', + id: item.id, + attemptGeneration: item.attemptGeneration, + deliveredMessageId: `message:${item.id}`, + nowIso: '2026-04-29T00:02:00.000Z', + }); + } + + await expect( + store.countRecentDelivered({ + teamName: 'team-a', + memberName: 'bob', + sinceIso: '2026-04-29T00:00:00.000Z', + }) + ).resolves.toBe(3); + await expect( + store.countRecentDelivered({ + teamName: 'team-a', + memberName: 'bob', + sinceIso: '2026-04-29T00:00:00.000Z', + workSyncIntentKeyPrefix: 'agenda-sync-still-stuck:', + }) + ).resolves.toBe(1); + }); + it('finds delivered review pickup request event ids from member-scoped outbox files', async () => { const input = { id: 'member-work-sync:team-a:bob:review-pickup:evt-a+evt-b', diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 15f67b34..3f3b80aa 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -372,6 +372,51 @@ async function forceRetryableOutboxDue(input: { ); } +async function backdateDeliveredOutboxItems(input: { + teamsBasePath: string; + teamName: string; + memberName: string; + updatedAt: string; +}): Promise { + const outboxPath = path.join( + input.teamsBasePath, + input.teamName, + 'members', + input.memberName, + '.member-work-sync', + 'outbox.json' + ); + const parsed = JSON.parse(await fs.promises.readFile(outboxPath, 'utf8')) as { + items?: Record; + }; + const touchedIds: string[] = []; + for (const [id, item] of Object.entries(parsed.items ?? {})) { + if (item.status === 'delivered') { + item.updatedAt = input.updatedAt; + touchedIds.push(id); + } + } + expect(touchedIds.length).toBeGreaterThan(0); + await fs.promises.writeFile(outboxPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8'); + + const indexPath = path.join( + input.teamsBasePath, + input.teamName, + '.member-work-sync', + 'indexes', + 'outbox-index.json' + ); + const index = JSON.parse(await fs.promises.readFile(indexPath, 'utf8')) as { + items?: Record; + }; + for (const id of touchedIds) { + if (index.items?.[id]) { + index.items[id].updatedAt = input.updatedAt; + } + } + await fs.promises.writeFile(indexPath, `${JSON.stringify(index, null, 2)}\n`, 'utf8'); +} + describe('createMemberWorkSyncFeature composition', () => { it('schedules proof-missing recovery through the work-sync queue', async () => { const claudeRoot = makeTempRoot(); @@ -1531,6 +1576,108 @@ describe('createMemberWorkSyncFeature composition', () => { } }); + it('delivers still-stuck recovery from json outbox when a delivered agenda nudge gets no report', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-json-still-stuck-recovery'; + const memberName = 'alice'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Recover ignored delivered sync', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + let agendaFingerprint = ''; + await waitForAssertion(async () => { + const status = await feature.getStatus({ teamName, memberName }); + agendaFingerprint = status.agenda.fingerprint; + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual([ + expect.objectContaining({ + status: 'delivered', + deliveredMessageId: nudges[0]?.messageId, + }), + ]); + }); + + await backdateDeliveredOutboxItems({ + teamsBasePath, + teamName, + memberName, + updatedAt: new Date(Date.now() - 10 * 60_000).toISOString(), + }); + await seedNativeStaleInProgressBlockingMetrics({ + teamsBasePath, + teamName, + memberName, + agendaFingerprint, + }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(2); + expect(nudges[1]?.messageId).toContain('agenda-sync-still-stuck'); + expect(nudges[1]?.text).toContain('still no accepted member_work_sync_report'); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: 'delivered', + deliveredMessageId: nudges[1]?.messageId, + }), + ]) + ); + }); + } finally { + await feature.dispose(); + } + }); + it('delivers targeted OpenCode nudges even when global phase2 metrics are noisy', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); diff --git a/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts b/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts index 6759ba7c..cc7c7b4b 100644 --- a/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts +++ b/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts @@ -1,12 +1,12 @@ import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; - import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; import { TeamRuntimeAdapterRegistry } from '../../../../src/main/services/team/runtime'; +import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; + import { buildOpenCodeScenarioTeamRequest, buildScenarioRuntimeMessageInput, @@ -87,6 +87,12 @@ describe('OpenCode production prompt artifacts safe e2e', () => { expect(member.prompt).toContain('AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1'); expect(member.prompt).toContain('agent-teams_message_send'); expect(member.prompt).toContain('Launch bootstrap is a silent attach'); + expect(member.prompt).toContain( + 'That bootstrap restriction is only about team registry/startup files' + ); + expect(member.prompt).toContain( + 'you may inspect, read/search, and edit files in the project working directory as your available tools allow' + ); expect(member.prompt).toContain('stay idle silently'); expect(member.prompt).not.toContain('Call SendMessage'); expect(member.prompt).not.toContain('Use SendMessage'); @@ -125,6 +131,8 @@ describe('OpenCode production prompt artifacts safe e2e', () => { expect(directCommand?.text).toContain('Include source="runtime_delivery"'); expect(directCommand?.text).toContain('Include relayOfMessageId="semantic-direct-'); expect(directCommand?.text).toContain('Action mode for this message: ask.'); + expect(directCommand?.text).toContain('Action mode ASK is read-only'); + expect(directCommand?.text).not.toContain('If this delivered message assigns implementation'); expect(directCommand?.text).toContain('You must not end this turn empty.'); expect(directCommand?.text).toContain('include taskRefs exactly as provided'); expect(directCommand?.text).toContain('"displayId":"59560c95"'); @@ -137,6 +145,8 @@ describe('OpenCode production prompt artifacts safe e2e', () => { expect(peerCommand?.text).toContain('to="jack"'); expect(peerCommand?.text).toContain('from="bob"'); expect(peerCommand?.text).toContain('Action mode for this message: delegate.'); + expect(peerCommand?.text).toContain('Action mode DELEGATE is orchestration-only'); + expect(peerCommand?.text).not.toContain('If this delivered message assigns implementation'); expect(peerCommand?.text).toContain('"displayId":"3375c939"'); expect(peerCommand?.taskRefs).toEqual([ { taskId: 'task-3375c939-peer-relay', displayId: '3375c939', teamName }, diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 41d5f240..c3ce08e2 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -704,6 +704,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => { ); const launchArg = launchOpenCodeTeam.mock.calls[0]?.[0]; expect(launchArg?.members[0]?.prompt).toContain('Do NOT create local team files'); + expect(launchArg?.members[0]?.prompt).toContain( + 'That bootstrap restriction is only about team registry/startup files' + ); + expect(launchArg?.members[0]?.prompt).toContain( + 'you may inspect, read/search, and edit files in the project working directory as your available tools allow' + ); expect(launchArg?.members[0]?.prompt).toContain('Launch bootstrap is a silent attach'); expect(launchArg?.members[0]?.prompt).toContain('stay idle silently'); expect(launchArg?.members[0]?.prompt).not.toContain('agent-teams_member_briefing'); @@ -1094,6 +1100,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).toContain('Include source="runtime_delivery"'); expect(sentText).toContain('Include relayOfMessageId="msg-1"'); expect(sentText).toContain('Action mode for this message: delegate.'); + expect(sentText).toContain('Action mode DELEGATE is orchestration-only'); + expect(sentText).not.toContain('If this delivered message assigns implementation'); expect(sentText).toContain('You must not end this turn empty.'); expect(sentText).toContain(''); expect(sentText).toContain('"kind":"opencode-delivery-context"'); @@ -1248,6 +1256,12 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).toContain('agent-teams_member_work_sync_status'); expect(sentText).toContain('agent-teams_member_work_sync_report'); expect(sentText).toContain('mcp__agent-teams__member_work_sync_report'); + expect(sentText).toContain('For agenda sync, only agent-teams_member_work_sync_report'); + expect(sentText).not.toContain('Concrete task progress'); + expect(sentText).toContain('If this delivered message assigns implementation'); + expect(sentText).toContain( + 'you may inspect, read/search, and edit files in the project working directory as your available tools allow' + ); expect(sentText).toContain('A status-only tool call is incomplete'); expect(sentText).toContain('teamName="team-a"'); expect(sentText).toContain('memberName="bob"'); @@ -1296,6 +1310,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).toContain('"workSyncReviewRequestEventIds":["evt-review-request"]'); expect(sentText).toContain('targeted member-work-sync review pickup nudge'); expect(sentText).toContain('review workflow tools'); + expect(sentText).toContain('Review workflow tool usage'); + expect(sentText).not.toContain('Concrete review progress'); expect(sentText).toContain('Do not mark the review complete from this prompt alone.'); expect(sentText).toContain('agent-teams_member_work_sync_report'); expect(sentText).toContain('A status-only tool call is incomplete'); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 3cbf8dcf..051dd8b7 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -209,6 +209,32 @@ type RuntimeTelemetryProcessTableRow = RuntimeProcessTableRow & { runtimeTelemetrySource?: 'native' | 'wsl' | 'windows-host'; }; +type LeadWorkSyncTestTaskRef = { taskId: string; displayId?: string; teamName?: string }; +type LeadWorkSyncTestInboxMessage = { + from: string; + to?: string; + text: string; + timestamp: string; + messageId: string; + read: boolean; + messageKind?: string; + taskRefs?: LeadWorkSyncTestTaskRef[]; +}; +type LeadWorkSyncReadCommitTestHarness = { + hasAcceptedLeadWorkSyncReport(input: { teamName: string; leadName: string }): Promise; + getLeadRelayReadCommitBatch(input: { + teamName: string; + leadName: string; + batch: LeadWorkSyncTestInboxMessage[]; + }): Promise; +}; + +function leadWorkSyncReadCommitHarness( + svc: TeamProvisioningService +): LeadWorkSyncReadCommitTestHarness { + return svc as unknown as LeadWorkSyncReadCommitTestHarness; +} + function restoreRuntimePidusageTelemetryEnv() { if (ORIGINAL_RUNTIME_PIDUSAGE_ENABLED === undefined) { delete process.env.CLAUDE_TEAM_RUNTIME_PIDUSAGE_ENABLED; @@ -11711,6 +11737,67 @@ describe('TeamProvisioningService', () => { } }); + it('keeps legacy OpenCode work-sync delivery pending without accepted report proof', async () => { + const previous = process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG; + process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG = '0'; + try { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + responseObservation: { + state: 'responded_non_visible_tool' as const, + deliveredUserMessageId: 'oc-user-legacy-work-sync', + assistantMessageId: 'oc-assistant-legacy-work-sync', + toolCallNames: ['member_work_sync_status', 'member_work_sync_report'], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: null, + }, + diagnostics: [], + })); + await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); + svc.setMemberWorkSyncAcceptedReportChecker(async () => false); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Work sync check for #task-1.', + messageId: 'msg-legacy-work-sync-report', + replyRecipient: 'team-lead', + actionMode: 'do', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + taskRefs: [ + { + taskId: 'task-1', + displayId: 'task-1', + teamName: 'team-a', + }, + ], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: true, + responseState: 'responded_non_visible_tool', + reason: 'member_work_sync_report_required', + }); + } finally { + if (previous === undefined) { + delete process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG; + } else { + process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG = previous; + } + } + }); + it('retries OpenCode direct asks after non-visible tool activity with an explicit retry header', async () => { const svc = new TeamProvisioningService(); const sendMessageToMember = vi.fn(async (input: Record) => ({ @@ -11977,6 +12064,7 @@ describe('TeamProvisioningService', () => { })); await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); svc.setControlApiBaseUrlResolver(async () => 'http://127.0.0.1:43123'); + svc.setMemberWorkSyncAcceptedReportChecker(async () => true); await expect( svc.deliverOpenCodeMemberMessage('team-a', { @@ -12010,6 +12098,155 @@ describe('TeamProvisioningService', () => { ); }); + it('accepts member work sync report proof even when OpenCode also sends a visible reply', async () => { + const svc = new TeamProvisioningService(); + const taskRef = { + taskId: 'task-1', + displayId: 'task-1', + teamName: 'team-a', + }; + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'responded_visible_message' as const, + deliveredUserMessageId: 'oc-user-work-sync-report-visible', + assistantMessageId: 'oc-assistant-work-sync-report-visible', + toolCallNames: ['member_work_sync_status', 'member_work_sync_report', 'message_send'], + visibleMessageToolCallId: 'call-visible-work-sync-report', + visibleReplyMessageId: 'visible-work-sync-report-reply', + visibleReplyCorrelation: 'relayOfMessageId' as const, + latestAssistantPreview: null, + reason: null, + }, + diagnostics: [], + })); + const observeMessageDelivery = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + responseObservation: { + state: 'responded_visible_message' as const, + deliveredUserMessageId: 'oc-user-work-sync-report-visible', + assistantMessageId: 'oc-assistant-work-sync-report-visible', + toolCallNames: ['member_work_sync_status', 'member_work_sync_report', 'message_send'], + visibleMessageToolCallId: 'call-visible-work-sync-report', + visibleReplyMessageId: 'visible-work-sync-report-reply', + visibleReplyCorrelation: 'relayOfMessageId' as const, + latestAssistantPreview: null, + reason: null, + }, + diagnostics: [], + })); + await configureOpenCodeBobDeliveryService({ + svc, + sendMessageToMember, + observeMessageDelivery, + }); + svc.setMemberWorkSyncAcceptedReportChecker(async () => true); + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'team-lead.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'team-lead', + text: 'I reported that I am still working on task-1.', + timestamp: '2026-04-25T10:00:01.000Z', + read: false, + messageId: 'visible-work-sync-report-reply', + relayOfMessageId: 'msg-work-sync-report-visible', + source: 'runtime_delivery', + taskRefs: [taskRef], + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Work sync check for #task-1.', + messageId: 'msg-work-sync-report-visible', + replyRecipient: 'team-lead', + actionMode: 'do', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + taskRefs: [taskRef], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: false, + responseState: 'responded_visible_message', + ledgerStatus: 'responded', + }); + }); + + it('keeps OpenCode member work sync report pending until the report is accepted', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'responded_non_visible_tool' as const, + deliveredUserMessageId: 'oc-user-work-sync-rejected-report', + assistantMessageId: 'oc-assistant-work-sync-rejected-report', + toolCallNames: ['member_work_sync_status', 'member_work_sync_report'], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: null, + }, + diagnostics: [], + })); + await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember }); + svc.setMemberWorkSyncAcceptedReportChecker(async () => false); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Work sync check for #task-1.', + messageId: 'msg-work-sync-rejected-report', + replyRecipient: 'team-lead', + actionMode: 'do', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + taskRefs: [ + { + taskId: 'task-1', + displayId: 'task-1', + teamName: 'team-a', + }, + ], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: true, + responseState: 'responded_non_visible_tool', + ledgerStatus: 'retry_scheduled', + reason: 'member_work_sync_report_required', + }); + }); + it('accepts review workflow tools as review pickup delivery response proof', async () => { const svc = new TeamProvisioningService(); const sendMessageToMember = vi.fn(async (input: Record) => ({ @@ -12062,6 +12299,145 @@ describe('TeamProvisioningService', () => { }); }); + it.each([ + { + name: 'plain text', + responseObservation: { + state: 'responded_plain_text' as const, + deliveredUserMessageId: 'oc-user-work-sync-plain', + assistantMessageId: 'oc-assistant-work-sync-plain', + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: 'I am still working on task-1 and will continue now.', + reason: null, + }, + }, + { + name: 'visible message', + seedVisibleReply: true, + responseObservation: { + state: 'responded_visible_message' as const, + deliveredUserMessageId: 'oc-user-work-sync-visible', + assistantMessageId: 'oc-assistant-work-sync-visible', + toolCallNames: ['agent-teams_message_send'], + visibleMessageToolCallId: 'call-visible-work-sync', + visibleReplyMessageId: 'visible-work-sync-reply', + visibleReplyCorrelation: 'relayOfMessageId' as const, + latestAssistantPreview: null, + reason: null, + }, + }, + { + name: 'task tool', + responseObservation: { + state: 'responded_non_visible_tool' as const, + deliveredUserMessageId: 'oc-user-work-sync-task-tool', + assistantMessageId: 'oc-assistant-work-sync-task-tool', + toolCallNames: ['task_start'], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: null, + }, + }, + { + name: 'agenda-sync review tool', + responseObservation: { + state: 'responded_non_visible_tool' as const, + deliveredUserMessageId: 'oc-user-work-sync-review-tool', + assistantMessageId: 'oc-assistant-work-sync-review-tool', + toolCallNames: ['review_start'], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: null, + }, + }, + ])( + 'keeps member work sync $name OpenCode deliveries pending without report proof', + async ({ responseObservation, seedVisibleReply }) => { + const svc = new TeamProvisioningService(); + const taskRef = { + taskId: 'task-1', + displayId: 'task-1', + teamName: 'team-a', + }; + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation, + diagnostics: [], + })); + const observeMessageDelivery = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + responseObservation, + diagnostics: [], + })); + await configureOpenCodeBobDeliveryService({ + svc, + sendMessageToMember, + observeMessageDelivery, + }); + if (seedVisibleReply) { + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'team-lead.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'team-lead', + text: 'I am still working on task-1 and will continue now.', + timestamp: '2026-04-25T10:00:01.000Z', + read: false, + messageId: 'visible-work-sync-reply', + relayOfMessageId: 'msg-work-sync-without-report-proof', + source: 'runtime_delivery', + taskRefs: [taskRef], + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + } + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Work sync check for #task-1.', + messageId: 'msg-work-sync-without-report-proof', + replyRecipient: 'team-lead', + actionMode: 'do', + messageKind: 'member_work_sync_nudge', + workSyncIntent: 'agenda_sync', + taskRefs: [taskRef], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: true, + responseState: responseObservation.state, + ledgerStatus: 'retry_scheduled', + reason: 'member_work_sync_report_required', + }); + } + ); + it('keeps member work sync status-only OpenCode deliveries pending', async () => { const svc = new TeamProvisioningService(); const sendMessageToMember = vi.fn(async (input: Record) => ({ @@ -12109,7 +12485,7 @@ describe('TeamProvisioningService', () => { responsePending: true, responseState: 'responded_non_visible_tool', ledgerStatus: 'retry_scheduled', - reason: 'non_visible_tool_without_task_progress', + reason: 'member_work_sync_report_required', }); }); @@ -23945,6 +24321,103 @@ describe('TeamProvisioningService', () => { }); }); + it('keeps lead work-sync inbox rows unread without accepted report or recovery', async () => { + const svc = new TeamProvisioningService(); + const harness = leadWorkSyncReadCommitHarness(svc); + const normalMessage = { + from: 'alice', + to: 'team-lead', + text: 'Please check task-1.', + timestamp: '2026-04-25T10:00:00.000Z', + messageId: 'msg-normal', + read: false, + }; + const workSyncMessage = { + from: 'system', + to: 'team-lead', + text: 'Work sync required.', + timestamp: '2026-04-25T10:00:01.000Z', + messageId: 'msg-work-sync', + messageKind: 'member_work_sync_nudge', + taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName: 'team-a' }], + read: false, + }; + vi.spyOn(harness, 'hasAcceptedLeadWorkSyncReport').mockResolvedValue(false); + + const readCommitBatch = await harness.getLeadRelayReadCommitBatch({ + teamName: 'team-a', + leadName: 'team-lead', + batch: [normalMessage, workSyncMessage], + }); + + expect(readCommitBatch).toEqual([normalMessage]); + }); + + it('read-commits lead work-sync inbox rows after accepted report proof', async () => { + const svc = new TeamProvisioningService(); + const harness = leadWorkSyncReadCommitHarness(svc); + const recoveryScheduler = vi.fn(); + const workSyncMessage = { + from: 'system', + to: 'team-lead', + text: 'Work sync required.', + timestamp: '2026-04-25T10:00:01.000Z', + messageId: 'msg-work-sync', + messageKind: 'member_work_sync_nudge', + taskRefs: [{ taskId: 'task-1', displayId: 'task-1', teamName: 'team-a' }], + read: false, + }; + vi.spyOn(harness, 'hasAcceptedLeadWorkSyncReport').mockResolvedValue(true); + svc.setMemberWorkSyncProofMissingRecoveryScheduler(recoveryScheduler); + + const readCommitBatch = await harness.getLeadRelayReadCommitBatch({ + teamName: 'team-a', + leadName: 'team-lead', + batch: [workSyncMessage], + }); + + expect(readCommitBatch).toEqual([workSyncMessage]); + expect(recoveryScheduler).not.toHaveBeenCalled(); + }); + + it('read-commits lead work-sync inbox rows when proof-missing recovery is queued', async () => { + const svc = new TeamProvisioningService(); + const harness = leadWorkSyncReadCommitHarness(svc); + const recoveryScheduler = vi.fn(async () => ({ + scheduled: true, + reason: 'scheduled', + intentKey: 'proof-missing:msg-work-sync', + })); + const taskRefs = [{ taskId: 'task-1', displayId: 'task-1', teamName: 'team-a' }]; + const workSyncMessage = { + from: 'system', + to: 'team-lead', + text: 'Work sync required.', + timestamp: '2026-04-25T10:00:01.000Z', + messageId: 'msg-work-sync', + messageKind: 'member_work_sync_nudge', + taskRefs, + read: false, + }; + vi.spyOn(harness, 'hasAcceptedLeadWorkSyncReport').mockResolvedValue(false); + svc.setMemberWorkSyncProofMissingRecoveryScheduler(recoveryScheduler); + + const readCommitBatch = await harness.getLeadRelayReadCommitBatch({ + teamName: 'team-a', + leadName: 'team-lead', + batch: [workSyncMessage], + }); + + expect(readCommitBatch).toEqual([workSyncMessage]); + expect(recoveryScheduler).toHaveBeenCalledWith({ + teamName: 'team-a', + memberName: 'team-lead', + originalMessageId: 'msg-work-sync', + taskRefs, + reason: 'lead_member_work_sync_report_required', + }); + }); + it('applies an unseen newer failure signal and transitions the member to failed_to_start', async () => { const latestHeartbeatAt = '2026-04-16T10:00:00.000Z'; const run = createMemberSpawnRun({ diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index dc3eec6a..226ddcac 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -1015,7 +1015,7 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain( 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' ); - expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future turns):'); + expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future lead turns):'); expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`); expect(prompt).toContain(