From 31c8dc3f05a9edb2908a47a6c64a244b9abc126a Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Thu, 23 Jul 2026 01:01:14 -0700 Subject: [PATCH] Keep completed OpenCode turns idle (#2336) Ignore post-turn metadata updates for user messages already emitted so completed OpenCode turns remain idle. --- .../agent/providers/opencode-agent.test.ts | 59 +++++++++++++++++++ .../server/agent/providers/opencode-agent.ts | 3 + 2 files changed, 62 insertions(+) diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index e7ac1e858..b9af8193c 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -2728,6 +2728,65 @@ describe("OpenCode provider subagent contract", () => { await parent.close(); } }); + + test("does not start a new autonomous turn when post-turn user message updates arrive for an already emitted message", async () => { + const { parent, openCode } = await createParentSession("ses_parent_post_turn"); + openCode.sessionPromptAsyncEvents = [ + ...userMessageEvents({ + sessionId: "ses_parent_post_turn", + messageId: "msg_user_1", + text: "Hello OpenCode", + }), + ...assistantTurnEvents({ + sessionId: "ses_parent_post_turn", + text: "Response from OpenCode", + }), + ]; + const events: AgentStreamEvent[] = []; + const streamDrained = createTestDeferred(); + parent.subscribe((event) => { + events.push(event); + if (event.type === "provider_subagent") { + streamDrained.resolve(); + } + }); + + try { + await parent.startTurn("Hello OpenCode"); + + await vi.waitFor(() => { + expect(events).toContainEqual(expect.objectContaining({ type: "turn_completed" })); + }); + + // Post-turn message update for the same user message ID that already completed + openCode.emitEvent({ + type: "message.updated", + properties: { + info: { id: "msg_user_1", sessionID: "ses_parent_post_turn", role: "user" }, + }, + }); + + openCode.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_child_drain_marker", + parentID: "ses_parent_post_turn", + title: "Stream drain marker", + directory: "/workspace/repo", + }, + }, + }); + + await streamDrained.promise; + + // Verify that no new turn_started was emitted after turn_completed + const turnStartedCount = events.filter((e) => e.type === "turn_started").length; + expect(turnStartedCount).toBe(1); + } finally { + await parent.close(); + } + }); test("does not adopt late output from an interrupted Paseo turn", async () => { const { parent, openCode } = await createParentSession("ses_parent_interrupted"); openCode.sessionPromptAsyncEvents = []; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 3ed1dff42..850cd0b4e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -3662,6 +3662,9 @@ class OpenCodeAgentSession implements AgentSession { // busy. That message is the earliest unambiguous boundary for a plugin- // initiated parent turn; session metadata and assistant echoes are not. if (event.type === "message.updated" && event.properties.info.role === "user") { + if (this.emittedUserMessageIds.has(event.properties.info.id)) { + return false; + } return true; } if (!this.externallyDriven) {