diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts index 90ce687ca591f..2d7b770d32676 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts @@ -364,6 +364,9 @@ function makeReplayClient(controller: OpenCodeReplayController): OpencodeClient status: () => request("mcp.status", {}), add: (input: unknown) => request("mcp.add", input), }, + app: { + agents: () => request("app.agents", {}), + }, } as unknown as OpencodeClient; } diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode_background_wake/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode_background_wake/opencode_transcript.ndjson index 119fde22bd9c1..f35e14a8571d6 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode_background_wake/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode_background_wake/opencode_transcript.ndjson @@ -2,6 +2,10 @@ {"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} {"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"title":"","permission":""}}} {"type":"emit_inbound","label":"session.created","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_1a0b5c3d4e5ffBgWakeRoot0001","slug":"wake-fixture","version":"1.19.5","projectID":"global","directory":"/tmp/t3-opencode-wake","path":"tmp/t3-opencode-wake","title":"T3 OpenCode background wake","time":{"created":1794000000000,"updated":1794000000000}}}} +{"type":"expect_outbound","label":"app.agents.agent-policy","frame":{"type":"app.agents","input":{}}} +{"type":"emit_inbound","label":"app.agents.agent-policy.response","frame":{"type":"sdk.response","operation":"app.agents","data":[{"name":"build","mode":"primary","options":{},"permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"ask"},{"permission":"external_directory","pattern":"*","action":"ask"},{"permission":"external_directory","pattern":"/home/user/.local/share/opencode/tool-output/*","action":"allow"},{"permission":"external_directory","pattern":"/tmp/opencode/*","action":"allow"},{"permission":"question","pattern":"*","action":"deny"},{"permission":"plan_enter","pattern":"*","action":"deny"},{"permission":"plan_exit","pattern":"*","action":"deny"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"ask"},{"permission":"read","pattern":"*.env.*","action":"ask"},{"permission":"read","pattern":"*.env.example","action":"allow"},{"permission":"question","pattern":"*","action":"allow"},{"permission":"plan_enter","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"/home/user/.local/share/opencode/tool-output/*","action":"allow"}]}]}} +{"type":"expect_outbound","label":"session.update.agent-policy","frame":{"type":"session.update","input":{"sessionID":"ses_1a0b5c3d4e5ffBgWakeRoot0001","permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"/home/user/.local/share/opencode/tool-output/*","action":"allow"},{"permission":"external_directory","pattern":"/tmp/opencode/*","action":"allow"},{"permission":"question","pattern":"*","action":"deny"},{"permission":"plan_enter","pattern":"*","action":"deny"},{"permission":"plan_exit","pattern":"*","action":"deny"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"allow"},{"permission":"read","pattern":"*.env.*","action":"allow"},{"permission":"read","pattern":"*.env.example","action":"allow"},{"permission":"question","pattern":"*","action":"allow"},{"permission":"plan_enter","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"/home/user/.local/share/opencode/tool-output/*","action":"allow"}]}}} +{"type":"emit_inbound","label":"session.update.agent-policy.response","frame":{"type":"sdk.response","operation":"session.update","data":{"id":"ses_1a0b5c3d4e5ffBgWakeRoot0001","slug":"wake-fixture","version":"1.19.5","projectID":"global","directory":"/tmp/t3-opencode-wake","path":"tmp/t3-opencode-wake","title":"T3 OpenCode background wake","time":{"created":1794000000000,"updated":1794000000000},"permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"/home/user/.local/share/opencode/tool-output/*","action":"allow"},{"permission":"external_directory","pattern":"/tmp/opencode/*","action":"allow"},{"permission":"question","pattern":"*","action":"deny"},{"permission":"plan_enter","pattern":"*","action":"deny"},{"permission":"plan_exit","pattern":"*","action":"deny"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"allow"},{"permission":"read","pattern":"*.env.*","action":"allow"},{"permission":"read","pattern":"*.env.example","action":"allow"},{"permission":"question","pattern":"*","action":"allow"},{"permission":"plan_enter","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"/home/user/.local/share/opencode/tool-output/*","action":"allow"}]}}} {"type":"expect_outbound","label":"session.promptAsync","frame":{"type":"session.promptAsync","input":{"sessionID":"ses_1a0b5c3d4e5ffBgWakeRoot0001","model":{"providerID":"openai","modelID":"gpt-5.4-mini"},"agent":"build","parts":[{"type":"text","text":"Use the task tool once with background set to true to delegate to the general subagent with the prompt: Respond exactly CHILD_OK. As soon as it is launched, reply exactly PARENT_OK and end your turn without waiting for it."}],"messageID":"msg_1a0b5c3d4e5fRootPrompt0001"}}} {"type":"emit_inbound","label":"session.promptAsync.response","frame":{"type":"sdk.response","operation":"session.promptAsync","data":null}} {"type":"emit_inbound","label":"root.user","frame":{"type":"sdk.event","event":{"id":"evt_1a0b5c3d4e5f0001WakeFixture","type":"message.updated","properties":{"sessionID":"ses_1a0b5c3d4e5ffBgWakeRoot0001","info":{"id":"msg_1a0b5c3d4e5fRootPrompt0001","role":"user","sessionID":"ses_1a0b5c3d4e5ffBgWakeRoot0001","time":{"created":1794000000001},"agent":"build","model":{"providerID":"openai","modelID":"gpt-5.4-mini"}}}}}} @@ -37,6 +41,8 @@ {"type":"emit_inbound","label":"wake.assistant.completed","frame":{"type":"sdk.event","event":{"id":"evt_1a0b5c3d4e5f0027WakeFixture","type":"message.updated","properties":{"sessionID":"ses_1a0b5c3d4e5ffBgWakeRoot0001","info":{"id":"msg_1a0b5c3d4e5fWakeAnswer0001","parentID":"msg_1a0b5c3d4e5fWakeResult0001","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/tmp/t3-opencode-wake","root":"/"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"gpt-5.4-mini","providerID":"openai","time":{"created":1794000000012,"completed":1794000000014},"sessionID":"ses_1a0b5c3d4e5ffBgWakeRoot0001","finish":"stop"}}}}} {"type":"emit_inbound","label":"wake.idle","frame":{"type":"sdk.event","event":{"id":"evt_1a0b5c3d4e5f0028WakeFixture","type":"session.status","properties":{"sessionID":"ses_1a0b5c3d4e5ffBgWakeRoot0001","status":{"type":"idle"}}}}} {"type":"emit_inbound","label":"wake.session.idle","frame":{"type":"sdk.event","event":{"id":"evt_1a0b5c3d4e5f0029WakeFixture","type":"session.idle","properties":{"sessionID":"ses_1a0b5c3d4e5ffBgWakeRoot0001"}}}} +{"type":"expect_outbound","label":"app.agents.agent-policy","frame":{"type":"app.agents","input":{}}} +{"type":"emit_inbound","label":"app.agents.agent-policy.response","frame":{"type":"sdk.response","operation":"app.agents","data":[{"name":"build","mode":"primary","options":{},"permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"ask"},{"permission":"external_directory","pattern":"*","action":"ask"},{"permission":"external_directory","pattern":"/home/user/.local/share/opencode/tool-output/*","action":"allow"},{"permission":"external_directory","pattern":"/tmp/opencode/*","action":"allow"},{"permission":"question","pattern":"*","action":"deny"},{"permission":"plan_enter","pattern":"*","action":"deny"},{"permission":"plan_exit","pattern":"*","action":"deny"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"ask"},{"permission":"read","pattern":"*.env.*","action":"ask"},{"permission":"read","pattern":"*.env.example","action":"allow"},{"permission":"question","pattern":"*","action":"allow"},{"permission":"plan_enter","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"/home/user/.local/share/opencode/tool-output/*","action":"allow"}]}]}} {"type":"expect_outbound","label":"session.abort.cleanup","frame":{"type":"session.abort","input":{"sessionID":"ses_1a0b5c3d4e5ffBgWakeRoot0001"}}} {"type":"emit_inbound","label":"session.abort.cleanup.response","frame":{"type":"sdk.response","operation":"session.abort","data":true}} {"type":"expect_outbound","label":"session.children.cleanup","frame":{"type":"session.children","input":{"sessionID":"ses_1a0b5c3d4e5ffBgWakeRoot0001"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode_child_approval/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode_child_approval/opencode_transcript.ndjson index 822060cd4913c..4729df7e15af6 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode_child_approval/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode_child_approval/opencode_transcript.ndjson @@ -2,6 +2,10 @@ {"type": "expect_outbound", "label": "event.subscribe", "frame": {"type": "event.subscribe"}} {"type": "expect_outbound", "label": "session.create", "frame": {"type": "session.create", "input": {"title": "", "permission": ""}}} {"type": "emit_inbound", "label": "session.created", "frame": {"type": "sdk.response", "operation": "session.create", "data": {"id": "ses_1235df8f8ffekByXP7gVnnXA3P", "slug": "kind-tiger", "version": "1.14.39", "projectID": "global", "directory": "/private/tmp/t3-opencode-probe", "path": "private/tmp/t3-opencode-probe", "title": "T3 OpenCode v2 probe", "time": {"created": 1781818066695, "updated": 1781818066695}}}} +{"type":"expect_outbound","label":"app.agents.agent-policy","frame":{"type":"app.agents","input":{}}} +{"type":"emit_inbound","label":"app.agents.agent-policy.response","frame":{"type":"sdk.response","operation":"app.agents","data":[{"name":"build","mode":"primary","options":{},"permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"ask"},{"permission":"external_directory","pattern":"*","action":"ask"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"ask"}]}]}} +{"type":"expect_outbound","label":"session.update.agent-policy","frame":{"type":"session.update","input":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"*","action":"allow"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"allow"}]}}} +{"type":"emit_inbound","label":"session.update.agent-policy.response","frame":{"type":"sdk.response","operation":"session.update","data":{"id":"ses_1235df8f8ffekByXP7gVnnXA3P","slug":"kind-tiger","version":"1.14.39","projectID":"global","directory":"/private/tmp/t3-opencode-probe","path":"private/tmp/t3-opencode-probe","title":"T3 OpenCode v2 probe","time":{"created":1781818066695,"updated":1781818066695},"permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"*","action":"allow"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"allow"}]}}} {"type":"expect_outbound","label":"session.promptAsync","frame":{"type":"session.promptAsync","input":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","model":{"providerID":"openai","modelID":"gpt-5.4-mini"},"agent":"build","parts":[{"type":"text","text":"Use the task tool exactly once. Delegate to the general subagent with this prompt: Respond exactly CHILD_OK. After the task completes, respond exactly PARENT_OK."}],"messageID":"msg_edca20735001Esh39V762TEx7f"}}} {"type": "emit_inbound", "label": "session.promptAsync.response", "frame": {"type": "sdk.response", "operation": "session.promptAsync", "data": null}} {"type": "emit_inbound", "label": "root.user", "frame": {"type": "sdk.event", "event": {"id": "evt_edca20735003a32yj1UtyUpHkU", "type": "message.updated", "properties": {"sessionID": "ses_1235df8f8ffekByXP7gVnnXA3P", "info": {"id": "msg_edca20735001Esh39V762TEx7f", "role": "user", "sessionID": "ses_1235df8f8ffekByXP7gVnnXA3P", "time": {"created": 1781818066741}, "agent": "build", "model": {"providerID": "openai", "modelID": "gpt-5.4-mini"}}}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode_running_child_approval/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode_running_child_approval/opencode_transcript.ndjson index 31e72c8f08772..2f550c84ca9df 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode_running_child_approval/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode_running_child_approval/opencode_transcript.ndjson @@ -2,6 +2,10 @@ {"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} {"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"title":"","permission":""}}} {"type":"emit_inbound","label":"session.created","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_1235df8f8ffekByXP7gVnnXA3P","slug":"kind-tiger","version":"1.14.39","projectID":"global","directory":"/private/tmp/t3-opencode-probe","path":"private/tmp/t3-opencode-probe","title":"T3 OpenCode v2 probe","time":{"created":1781818066695,"updated":1781818066695}}}} +{"type":"expect_outbound","label":"app.agents.agent-policy","frame":{"type":"app.agents","input":{}}} +{"type":"emit_inbound","label":"app.agents.agent-policy.response","frame":{"type":"sdk.response","operation":"app.agents","data":[{"name":"build","mode":"primary","options":{},"permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"ask"},{"permission":"external_directory","pattern":"*","action":"ask"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"ask"}]}]}} +{"type":"expect_outbound","label":"session.update.agent-policy","frame":{"type":"session.update","input":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"*","action":"allow"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"allow"}]}}} +{"type":"emit_inbound","label":"session.update.agent-policy.response","frame":{"type":"sdk.response","operation":"session.update","data":{"id":"ses_1235df8f8ffekByXP7gVnnXA3P","slug":"kind-tiger","version":"1.14.39","projectID":"global","directory":"/private/tmp/t3-opencode-probe","path":"private/tmp/t3-opencode-probe","title":"T3 OpenCode v2 probe","time":{"created":1781818066695,"updated":1781818066695},"permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"*","action":"allow"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"allow"}]}}} {"type":"expect_outbound","label":"session.promptAsync","frame":{"type":"session.promptAsync","input":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","model":{"providerID":"openai","modelID":"gpt-5.4-mini"},"agent":"build","parts":[{"type":"text","text":"Use the task tool exactly once. Delegate to the general subagent with this prompt: Respond exactly CHILD_OK. After the task completes, respond exactly PARENT_OK."}],"messageID":"msg_edca20735001Esh39V762TEx7f"}}} {"type":"emit_inbound","label":"session.promptAsync.response","frame":{"type":"sdk.response","operation":"session.promptAsync","data":null}} {"type":"emit_inbound","label":"root.user","frame":{"type":"sdk.event","event":{"id":"evt_edca20735003a32yj1UtyUpHkU","type":"message.updated","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","info":{"id":"msg_edca20735001Esh39V762TEx7f","role":"user","sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","time":{"created":1781818066741},"agent":"build","model":{"providerID":"openai","modelID":"gpt-5.4-mini"}}}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode_subagent/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode_subagent/opencode_transcript.ndjson index 182bf5ed2c710..87d124013aadc 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode_subagent/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode_subagent/opencode_transcript.ndjson @@ -2,6 +2,10 @@ {"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} {"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"title":"","permission":""}}} {"type":"emit_inbound","label":"session.created","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_1235df8f8ffekByXP7gVnnXA3P","slug":"kind-tiger","version":"1.14.39","projectID":"global","directory":"/private/tmp/t3-opencode-probe","path":"private/tmp/t3-opencode-probe","title":"T3 OpenCode v2 probe","time":{"created":1781818066695,"updated":1781818066695}}}} +{"type":"expect_outbound","label":"app.agents.agent-policy","frame":{"type":"app.agents","input":{}}} +{"type":"emit_inbound","label":"app.agents.agent-policy.response","frame":{"type":"sdk.response","operation":"app.agents","data":[{"name":"build","mode":"primary","options":{},"permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"ask"},{"permission":"external_directory","pattern":"*","action":"ask"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"ask"}]}]}} +{"type":"expect_outbound","label":"session.update.agent-policy","frame":{"type":"session.update","input":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"*","action":"allow"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"allow"}]}}} +{"type":"emit_inbound","label":"session.update.agent-policy.response","frame":{"type":"sdk.response","operation":"session.update","data":{"id":"ses_1235df8f8ffekByXP7gVnnXA3P","slug":"kind-tiger","version":"1.14.39","projectID":"global","directory":"/private/tmp/t3-opencode-probe","path":"private/tmp/t3-opencode-probe","title":"T3 OpenCode v2 probe","time":{"created":1781818066695,"updated":1781818066695},"permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"*","action":"allow"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"allow"}]}}} {"type":"expect_outbound","label":"session.promptAsync","frame":{"type":"session.promptAsync","input":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","model":{"providerID":"openai","modelID":"gpt-5.4-mini"},"agent":"build","parts":[{"type":"text","text":"Use the task tool exactly once. Delegate to the general subagent with this prompt: Respond exactly CHILD_OK. After the task completes, respond exactly PARENT_OK."}],"messageID":"msg_edca20735001Esh39V762TEx7f"}}} {"type":"emit_inbound","label":"session.promptAsync.response","frame":{"type":"sdk.response","operation":"session.promptAsync","data":null}} {"type":"emit_inbound","label":"root.user","frame":{"type":"sdk.event","event":{"id":"evt_edca20735003a32yj1UtyUpHkU","type":"message.updated","properties":{"sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","info":{"id":"msg_edca20735001Esh39V762TEx7f","role":"user","sessionID":"ses_1235df8f8ffekByXP7gVnnXA3P","time":{"created":1781818066741},"agent":"build","model":{"providerID":"openai","modelID":"gpt-5.4-mini"}}}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/opencode_transcript.ndjson index cef5cd8213d56..257dfad48f76c 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/opencode_transcript.ndjson @@ -2,6 +2,10 @@ {"type":"expect_outbound","label":"event.subscribe","frame":{"type":"event.subscribe"}} {"type":"expect_outbound","label":"session.create","frame":{"type":"session.create","input":{"title":"","permission":""}}} {"type":"emit_inbound","label":"session.created","frame":{"type":"sdk.response","operation":"session.create","data":{"id":"ses_1236c522dffeunHBidt20OIzWc","slug":"shiny-tiger","version":"1.14.39","projectID":"global","directory":"/private/tmp/t3-opencode-probe","path":"private/tmp/t3-opencode-probe","title":"T3 OpenCode v2 probe","time":{"created":1781817126354,"updated":1781817126354}}}} +{"type":"expect_outbound","label":"app.agents.agent-policy","frame":{"type":"app.agents","input":{}}} +{"type":"emit_inbound","label":"app.agents.agent-policy.response","frame":{"type":"sdk.response","operation":"app.agents","data":[{"name":"build","mode":"primary","options":{},"permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"ask"},{"permission":"external_directory","pattern":"*","action":"ask"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"ask"}]}]}} +{"type":"expect_outbound","label":"session.update.agent-policy","frame":{"type":"session.update","input":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"*","action":"allow"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"allow"}]}}} +{"type":"emit_inbound","label":"session.update.agent-policy.response","frame":{"type":"sdk.response","operation":"session.update","data":{"id":"ses_1236c522dffeunHBidt20OIzWc","slug":"shiny-tiger","version":"1.14.39","projectID":"global","directory":"/private/tmp/t3-opencode-probe","path":"private/tmp/t3-opencode-probe","title":"T3 OpenCode v2 probe","time":{"created":1781817126354,"updated":1781817126354},"permission":[{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"*","pattern":"*","action":"allow"},{"permission":"doom_loop","pattern":"*","action":"allow"},{"permission":"external_directory","pattern":"*","action":"allow"},{"permission":"read","pattern":"*","action":"allow"},{"permission":"read","pattern":"*.env","action":"allow"}]}}} {"type":"expect_outbound","label":"session.promptAsync","frame":{"type":"session.promptAsync","input":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","model":{"providerID":"openai","modelID":"gpt-5.4-mini"},"agent":"build","parts":[{"type":"text","text":"Respond with the following text: fixture simple ok"}],"messageID":"msg_edc93addd001jzzrE6lotn7BJj"}}} {"type":"emit_inbound","label":"session.promptAsync.response","frame":{"type":"sdk.response","operation":"session.promptAsync","data":null}} {"type":"emit_inbound","label":"message.updated.user","frame":{"type":"sdk.event","event":{"id":"evt_edc93ade5002giWB7gMKgKTh1N","type":"message.updated","properties":{"sessionID":"ses_1236c522dffeunHBidt20OIzWc","info":{"role":"user","time":{"created":1781817126365},"agent":"build","model":{"providerID":"openai","modelID":"gpt-5.4-mini"},"id":"msg_edc93addd001jzzrE6lotn7BJj","sessionID":"ses_1236c522dffeunHBidt20OIzWc","summary":{"diffs":[]}}}}}} diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index c28e6ff52b34b..d0efe61dc42b2 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -5,7 +5,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"; +import type { OpencodeClient, PermissionRuleset, ToolPart } from "@opencode-ai/sdk/v2"; import { toolOutputImages } from "@t3tools/shared/toolOutput"; import { CheckpointId, @@ -19,6 +19,7 @@ import { RunId, MessageId, ThreadId, + type ModelSelection, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, type ProviderOptionSelection, @@ -165,7 +166,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct ) { const instanceId = ProviderInstanceId.make(`opencode-${suffix}`); const threadId = ThreadId.make(`thread-opencode-${suffix}`); - const modelSelection = { + const modelSelection: ModelSelection = { instanceId, model: turnSettings.model ?? "anthropic/claude-sonnet", options: turnSettings.options ?? [], @@ -213,8 +214,10 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct resume = false, turnKey = suffix, continuation?: { model?: string }, + turnRuntime: ProviderAdapter.ProviderAdapterV2SessionRuntime = runtime, + turnModelSelection: ModelSelection = modelSelection, ) => - runtime.startTurn({ + turnRuntime.startTurn({ appThread: { id: threadId, projectId: ProjectId.make(`project-opencode-${suffix}`), @@ -253,10 +256,15 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct text, attachments: [], }, - modelSelection: { ...modelSelection, model: continuation?.model ?? modelSelection.model }, + modelSelection: { + ...turnModelSelection, + model: continuation?.model ?? turnModelSelection.model, + }, runtimePolicy: turnPolicy, }); return { + adapter, + modelSelection, nativeSessionId, now, policy, @@ -370,6 +378,7 @@ describe("OpenCodeAdapterV2", () => { }; }, }, + app: { agents: async () => ({ data: [{ name: "plan", permission: [] }] }) }, command: { list: async () => ({ data: [{ name: "review" }] }) }, session: { create: async () => ({ data: { id: suffix, time: { created: 1, updated: 1 } } }), @@ -745,32 +754,39 @@ describe("OpenCodeAdapterV2", () => { [child, [taskDeny]], ]); let failNextChildUpdate = false; - const harness = yield* makeOpenCodeRuntimeHarness("child-policy-change", root, { - event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, - session: { - create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }), - get: async ({ sessionID }: { sessionID: string }) => ({ - data: { - id: sessionID, - ...(sessionID === child ? { parentID: root } : {}), - permission: nativePermissions.get(sessionID), - time: { created: 1, updated: 1 }, + const harness = yield* makeOpenCodeRuntimeHarness( + "child-policy-change", + root, + { + event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + app: { agents: async () => ({ data: [{ name: "review", permission: [taskDeny] }] }) }, + session: { + create: async () => ({ data: { id: root, time: { created: 1, updated: 1 } } }), + get: async ({ sessionID }: { sessionID: string }) => ({ + data: { + id: sessionID, + ...(sessionID === child ? { parentID: root } : {}), + permission: nativePermissions.get(sessionID), + time: { created: 1, updated: 1 }, + }, + }), + update: async (input: (typeof updates)[number]) => { + if (input.sessionID === child && failNextChildUpdate) { + failNextChildUpdate = false; + throw new Error("child update failed"); + } + updates.push(input); + nativePermissions.set(input.sessionID, input.permission); + return { data: { id: input.sessionID, time: { created: 1, updated: 2 } } }; }, - }), - update: async (input: (typeof updates)[number]) => { - if (input.sessionID === child && failNextChildUpdate) { - failNextChildUpdate = false; - throw new Error("child update failed"); - } - updates.push(input); - nativePermissions.set(input.sessionID, input.permission); - return { data: { id: input.sessionID, time: { created: 1, updated: 2 } } }; + promptAsync: async () => ({ data: true }), + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), }, - promptAsync: async () => ({ data: true }), - abort: async () => ({ data: true }), - children: async () => ({ data: [] }), }, - }); + {}, + { options: [{ id: "agent", value: "review" }] }, + ); yield* harness.startTurn("hello", harness.providerThread, fullAccess); yield* push({ @@ -3610,9 +3626,11 @@ describe("OpenCodeAdapterV2", () => { "root", { event: { subscribe: async () => ({ stream: nativeEvents.stream }) }, + app: { agents: async () => ({ data: [{ name: "plan", permission: [] }] }) }, session: { create: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), get: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), + update: async () => ({ data: { id: "root", time: { created: 1, updated: 1 } } }), messages: async () => ({ data: [] }), promptAsync: async (input: { agent?: string; variant?: string; system?: string }) => { promptInputs.push(input); @@ -4882,6 +4900,188 @@ describe("OpenCodeAdapterV2", () => { ), ); + it.effect("keeps the selected agent's denies under full access", () => + Effect.gen(function* () { + const review: PermissionRuleset = [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "*", pattern: "*", action: "deny" }, + { permission: "read", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: "deny" }, + { permission: "bash", pattern: "*", action: "deny" }, + ]; + const sessions = new Map< + string, + { readonly id: string; readonly permission?: PermissionRuleset } + >(); + const prompts: Array<{ readonly agent: unknown; readonly permission: PermissionRuleset }> = + []; + const client = { + event: { + subscribe: async (_input?: unknown, options?: { readonly signal?: AbortSignal }) => ({ + stream: { + [Symbol.asyncIterator]: () => ({ + next: () => + new Promise>((resolve) => { + const done = () => resolve({ done: true, value: undefined }); + if (options?.signal?.aborted) return done(); + options?.signal?.addEventListener("abort", done, { once: true }); + }), + }), + }, + }), + }, + app: { + agents: async () => ({ + data: [ + { name: "review", mode: "primary", options: {}, permission: review }, + { name: "plan", mode: "primary", options: {}, permission: review }, + ], + }), + }, + session: { + create: async (input: { readonly permission?: PermissionRuleset }) => { + const nativeSession = { + id: "native-review", + permission: input.permission ?? [], + time: { created: 1, updated: 1 }, + }; + sessions.set(nativeSession.id, nativeSession); + return { data: nativeSession }; + }, + get: async ({ sessionID }: { readonly sessionID: string }) => ({ + data: sessions.get(sessionID), + }), + update: async (input: { + readonly sessionID: string; + readonly permission: PermissionRuleset; + }) => { + const existing = sessions.get(input.sessionID)!; + // OpenCode appends updated rules after the session's own. + const nativeSession = { + ...existing, + permission: [...(existing.permission ?? []), ...input.permission], + }; + sessions.set(input.sessionID, nativeSession); + return { data: nativeSession }; + }, + promptAsync: async (input: { readonly sessionID: string; readonly agent?: string }) => { + prompts.push({ + agent: input.agent, + permission: sessions.get(input.sessionID)?.permission ?? [], + }); + return { data: true }; + }, + abort: async () => ({ data: true }), + children: async () => ({ data: [] }), + }, + }; + const harness = yield* makeOpenCodeRuntimeHarness( + "agent-denies", + "native-review", + client, + {}, + { options: [{ id: "agent", value: "review" }] }, + ); + const noAgent = { ...harness.modelSelection, options: [] }; + const reopen = Effect.fn("reopen")(function* (suffix: string) { + const runtime = yield* harness.adapter.openSession({ + threadId: harness.threadId, + providerSessionId: ProviderSessionId.make(`session-opencode-agent-denies-${suffix}`), + modelSelection: harness.modelSelection, + runtimePolicy: harness.policy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId: harness.threadId, + modelSelection: harness.modelSelection, + runtimePolicy: harness.policy, + existingProviderThread: harness.providerThread, + }); + return { runtime, providerThread }; + }); + const lastPermission = (permission: string) => + permissionAction(prompts.at(-1)!.permission, permission); + + yield* harness.startTurn("Review the change"); + assert.equal(lastPermission("bash"), "deny"); + assert.equal(lastPermission("edit"), "deny"); + // Full access never prompts, so the agent's asks become allows. + assert.equal(lastPermission("external_directory"), "allow"); + + const resumed = yield* reopen("resumed"); + yield* harness.startTurn( + "Again", + resumed.providerThread, + harness.policy, + false, + undefined, + undefined, + resumed.runtime, + ); + assert.deepEqual(prompts[1]?.permission, prompts[0]?.permission); + + const plain = yield* reopen("plain"); + yield* harness.startTurn( + "Fix it", + plain.providerThread, + harness.policy, + false, + undefined, + undefined, + plain.runtime, + noAgent, + ); + assert.equal(prompts.at(-1)?.agent, undefined); + assert.equal(lastPermission("bash"), "allow"); + + const planned = yield* reopen("plan"); + yield* harness.startTurn( + "Plan it", + planned.providerThread, + { ...harness.policy, interactionMode: "plan" }, + false, + undefined, + undefined, + planned.runtime, + noAgent, + ); + assert.equal(prompts.at(-1)?.agent, "plan"); + assert.equal(lastPermission("edit"), "deny"); + + const supervised = yield* reopen("supervised"); + yield* harness.startTurn( + "Ask first", + supervised.providerThread, + runtimePolicy("approval-required"), + false, + undefined, + undefined, + supervised.runtime, + ); + assert.equal(lastPermission("bash"), "ask"); + + const missing = yield* reopen("missing"); + const beforeMissing = prompts.length; + const result = yield* Effect.exit( + harness.startTurn( + "Nope", + missing.providerThread, + harness.policy, + false, + undefined, + undefined, + missing.runtime, + { ...harness.modelSelection, options: [{ id: "agent", value: "missing" }] }, + ), + ); + assert(Exit.isFailure(result)); + assert.equal(prompts.length, beforeMissing); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it("maps runtime modes to safe OpenCode permission rules", () => { const approvalRequired = openCodePermissionRules(runtimePolicy("approval-required")); assert.equal(permissionAction(approvalRequired, "read"), "allow"); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 2f645c58ae7c8..ccceaa27959b6 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -734,6 +734,16 @@ export function openCodePermissionRules( return rules; } +function openCodeTurnAgent( + modelSelection: ModelSelection, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, +): string | undefined { + return ( + getModelSelectionStringOptionValue(modelSelection, "agent") ?? + (runtimePolicy.interactionMode === "plan" ? "plan" : undefined) + ); +} + function permissionRuleEquals( left: PermissionRuleset[number], right: PermissionRuleset[number], @@ -3342,18 +3352,46 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const applyPermissionRules = Effect.fnUntraced(function* ( state: OpenCodeThreadState, policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + modelSelection: ModelSelection, ) { const parentRules = openCodePermissionRules(policy); + let rootRules = parentRules; + const agent = openCodeTurnAgent(modelSelection, policy); + if ( + state.parentSubagent === null && + agent !== undefined && + parentRules.every((rule) => rule.action === "allow") + ) { + const agents = unwrapData( + "app.agents", + yield* sdkCall("app.agents", {}, (signal) => + client.app.agents(undefined, { signal }), + ), + ); + const selected = agents.find((entry) => entry.name === agent); + if (selected === undefined) { + return yield* protocolError(`OpenCode agent '${agent}' is not available`); + } + // OpenCode takes the last matching rule; full access must not shadow agent denies. + rootRules = [ + ...parentRules, + ...selected.permission.map((rule) => + rule.action === "ask" ? { ...rule, action: "allow" as const } : rule, + ), + ]; + } const pending = [state]; for (let target = pending.pop(); target !== undefined; target = pending.pop()) { const previous = target.permission; const permission = target.parentSubagent === null - ? parentRules + ? rootRules : openCodeChildPermissionRules(policy, previous, target.inheritedPermission); if ( - permission.length !== previous.length || - !permission.every((rule, index) => permissionRuleEquals(rule, previous[index]!)) + permission.length > previous.length || + !permission.every((rule, index) => + permissionRuleEquals(rule, previous[previous.length - permission.length + index]!), + ) ) { const sessionID = target.nativeSessionId; yield* sdkCall("session.update", { sessionID, permission }, () => @@ -3462,9 +3500,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function // OpenCode runs each step with the agent and system prompt of the latest // user message, so every prompt in a turn, steers included, repeats them. const turnPromptOptions = (turn: ActiveOpenCodeTurn) => { - const agent = - getModelSelectionStringOptionValue(turn.modelSelection, "agent") ?? - (turn.runtimePolicy.interactionMode === "plan" ? "plan" : undefined); + const agent = openCodeTurnAgent(turn.modelSelection, turn.runtimePolicy); const variant = getModelSelectionStringOptionValue(turn.modelSelection, "variant"); return { ...(agent === undefined ? {} : { agent }), @@ -3658,7 +3694,11 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }; const state = registerThread(nativeSession, providerThread, null); if (threadInput.runtimePolicy !== undefined) { - yield* applyPermissionRules(state, threadInput.runtimePolicy); + yield* applyPermissionRules( + state, + threadInput.runtimePolicy, + threadInput.modelSelection ?? input.modelSelection, + ); } return providerThread; }, @@ -3711,7 +3751,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function `OpenCode model '${turnInput.modelSelection.model}' must use provider/model format`, ); } - yield* applyPermissionRules(state, turnInput.runtimePolicy); + yield* applyPermissionRules(state, turnInput.runtimePolicy, turnInput.modelSelection); // A continuation admits a wake OpenCode is already running, so it sends no prompt. const isCompaction = !continuation &&