From 799eb5c22c66a87635ac1d635a6f217cfe872ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 24 Sep 2026 22:27:07 +0300 Subject: [PATCH 1/6] fix: ignore transient tool idle boundaries --- src/source/runtime/session-status.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/source/runtime/session-status.js b/src/source/runtime/session-status.js index a59d15c..b0aa4da 100644 --- a/src/source/runtime/session-status.js +++ b/src/source/runtime/session-status.js @@ -113,7 +113,17 @@ export function createSessionStatusRuntime(options = {}) { async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) { if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false - if (!options.requireIdle && !options.forceStale) return true + + // OpenCode 1.x can emit transient session.idle boundaries between tool + // steps while the same assistant turn is still in progress. When the + // message API can prove that the current assistant message is incomplete, + // keep the Loop-owned run active instead of treating that transient idle + // as a completed turn. Preserve the historical fallback when message + // completion evidence is unavailable. + if (!options.requireIdle && !options.forceStale) { + const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active) + return completion !== "incomplete" + } const completion = options.forceStale ? await activeRunCompletionFromMessages(directory, client, sessionID, active) From 3247cabf1cb6e40a17710bc1f3cb8f25ec4cb8e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 24 Sep 2026 22:27:14 +0300 Subject: [PATCH 2/6] test: cover transient tool idle finalization --- scripts/session-status-test.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/session-status-test.mjs b/scripts/session-status-test.mjs index e26f3ff..1637e2c 100644 --- a/scripts/session-status-test.mjs +++ b/scripts/session-status-test.mjs @@ -231,6 +231,17 @@ try { track(sessionID) runtime.clearSessionStatus(sessionID) const active = { jobId: "job", job: {}, startedAt: clock - 100 } + completion = "unknown" + assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), true) + + completion = "incomplete" + assert.equal( + await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), + false, + "a transient idle boundary must not finalize a Loop-owned run while the assistant turn is still incomplete", + ) + + completion = "completed" assert.equal(await runtime.canFinalizeActiveRun("/repo", {}, sessionID, active), true) markToolCallActive({ sessionID, callID: "tool-finalize" }) @@ -240,6 +251,7 @@ try { runtime.markSessionStatus(sessionID, "idle", clock + 1) const client = { session: { status: async () => { throw new Error("status unavailable") } } } assert.equal(await runtime.canFinalizeActiveRun("/repo", client, sessionID, active, { requireIdle: true }), true) + completion = "unknown" } { From 78724b6726da184dce57d0a9943521cee1477c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 24 Sep 2026 22:30:27 +0300 Subject: [PATCH 3/6] fix: distinguish missing completion evidence --- src/source/opencode/host.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/source/opencode/host.js b/src/source/opencode/host.js index 34330b7..f5aa5c7 100644 --- a/src/source/opencode/host.js +++ b/src/source/opencode/host.js @@ -69,6 +69,7 @@ export async function activeRunCompletionFromMessages(directory, client, session const messages = await readRecentSessionMessages(client, sessionID, directory) if (!messages) return "unknown" const ordered = orderedSessionMessages(messages) + if (ordered.length === 0) return "unknown" const tail = ordered.at(-1) const info = tail?.info || tail if (!info || info.role !== "assistant") return "incomplete" From 714c3f9d0199057dd4f7e740964014a255150cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 24 Sep 2026 22:30:33 +0300 Subject: [PATCH 4/6] test: preserve fallback with no message evidence --- scripts/host-adapter-contract-test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/host-adapter-contract-test.mjs b/scripts/host-adapter-contract-test.mjs index 1263077..dd808ea 100644 --- a/scripts/host-adapter-contract-test.mjs +++ b/scripts/host-adapter-contract-test.mjs @@ -57,6 +57,7 @@ assert.equal(compactTuiCommandName("other"), undefined) assert.deepEqual(ordered.map((item) => item.info.id), ["a", "b", "c"]) assert.equal(await activeRunCompletionFromMessages("/tmp", {}, "missing", { startedAt: 100 }), "unknown") + assert.equal(await activeRunCompletionFromMessages("/tmp", messagesClient([]), "empty-history", { startedAt: 100 }), "unknown") assert.equal(await activeRunCompletionFromMessages( "/tmp", messagesClient([{ info: { role: "assistant", time: { created: 50, completed: 90 } } }]), "old", { startedAt: 100 }, ), "incomplete") From af7aa02a795594f6790070e48682a38820ed5ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 24 Sep 2026 22:32:13 +0300 Subject: [PATCH 5/6] build: refresh generated Loop bundles --- src/index.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 02ecc20..cd83dd0 100644 --- a/src/index.js +++ b/src/index.js @@ -819,6 +819,8 @@ async function activeRunCompletionFromMessages(directory, client, sessionID, act if (!messages) return "unknown"; const ordered = orderedSessionMessages(messages); + if (ordered.length === 0) + return "unknown"; const tail = ordered.at(-1); const info = tail?.info || tail; if (!info || info.role !== "assistant") @@ -2994,8 +2996,10 @@ function createSessionStatusRuntime(options = {}) { async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) { if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false; - if (!options.requireIdle && !options.forceStale) - return true; + if (!options.requireIdle && !options.forceStale) { + const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active); + return completion !== "incomplete"; + } const completion = options.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined; if (settledAssistantCompletion(completion)) return true; From 8f25dc84f61b2e8aec712b19a8c8520cf5fcd358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Thu, 24 Sep 2026 22:32:16 +0300 Subject: [PATCH 6/6] build: refresh generated Loop bundles --- src/server.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/server.js b/src/server.js index 2423c5d..30e9412 100644 --- a/src/server.js +++ b/src/server.js @@ -819,6 +819,8 @@ async function activeRunCompletionFromMessages(directory, client, sessionID, act if (!messages) return "unknown"; const ordered = orderedSessionMessages(messages); + if (ordered.length === 0) + return "unknown"; const tail = ordered.at(-1); const info = tail?.info || tail; if (!info || info.role !== "assistant") @@ -2994,8 +2996,10 @@ function createSessionStatusRuntime(options = {}) { async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) { if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false; - if (!options.requireIdle && !options.forceStale) - return true; + if (!options.requireIdle && !options.forceStale) { + const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active); + return completion !== "incomplete"; + } const completion = options.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined; if (settledAssistantCompletion(completion)) return true;