diff --git a/scripts/host-adapter-contract-test.mjs b/scripts/host-adapter-contract-test.mjs index 1263077e..dd808ea2 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") diff --git a/scripts/session-status-test.mjs b/scripts/session-status-test.mjs index e26f3ff1..1637e2c6 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" } { diff --git a/src/index.js b/src/index.js index 02ecc209..cd83dd08 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; diff --git a/src/server.js b/src/server.js index 2423c5d2..30e9412a 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; diff --git a/src/source/opencode/host.js b/src/source/opencode/host.js index 34330b74..f5aa5c79 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" diff --git a/src/source/runtime/session-status.js b/src/source/runtime/session-status.js index a59d15c8..b0aa4da8 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)