diff --git a/tests/web/app-render.test.ts b/tests/web/app-render.test.ts index c35a3f88..46848da0 100644 --- a/tests/web/app-render.test.ts +++ b/tests/web/app-render.test.ts @@ -867,6 +867,58 @@ test("app.js settles an admitted prompt that Pi handles without an agent turn", assert.equal((app.state.terminalPromptIds as Set).size, 32); }); +test("app.js keeps an active agent running when a handled prompt settles", async () => { + const app = await renderApp(); + vm.runInContext( + 'applyRuntimeEvent({sequence: 2, type: "agent_start", detail: {sessionId: "s1"}}); applyRuntimeEvent({sequence: 3, type: "prompt_settled", detail: {sessionId: "s1", commandId: "handled"}}); applyRuntimeEvent({sequence: 4, type: "prompt_accepted", detail: {sessionId: "s1", commandId: "handled"}})', + app.context as vm.Context, + ); + assert.equal(app.state.liveRunning, true); + assert.equal(app.state.livePhase, "running"); + + vm.runInContext( + 'applyRuntimeEvent({sequence: 5, type: "agent_settled", detail: {sessionId: "s1"}})', + app.context as vm.Context, + ); + assert.equal(app.state.liveRunning, false); + assert.equal(app.state.livePhase, "idle"); +}); + +test("app.js keeps an active agent running when a queued prompt is accepted", async () => { + const app = await renderApp(); + vm.runInContext( + 'applyRuntimeEvent({sequence: 2, type: "agent_start", detail: {sessionId: "s1"}}); applyRuntimeEvent({sequence: 3, type: "prompt_accepted", detail: {sessionId: "s1", commandId: "queued"}})', + app.context as vm.Context, + ); + assert.equal(app.state.liveRunning, true); + assert.equal(app.state.livePhase, "running"); +}); + +test("app.js keeps an active agent running when its prompt receipt settles late", async () => { + const app = await renderApp(); + const prompt = deferred>(); + app.context.fetch = async (url: unknown) => { + if (String(url) === "/api/prompt") return prompt.promise; + if (String(url).startsWith("/api/snapshot")) return response(SNAPSHOT); + throw new Error(`unexpected request: ${String(url)}`); + }; + const input = app.elements.get("prompt-input"); + assert.ok(input); + input.value = "late receipt"; + const sending = app.sendPrompt(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + vm.runInContext( + 'applyRuntimeEvent({sequence: 2, type: "agent_start", detail: {sessionId: "s1"}}); applyRuntimeEvent({sequence: 3, type: "prompt_settled", detail: {sessionId: "s1", commandId: "late"}})', + app.context as vm.Context, + ); + prompt.resolve(response({ id: "late", accepted: true })); + await sending; + + assert.equal(app.state.liveRunning, true); + assert.equal(app.state.livePhase, "running"); +}); + test("app.js scopes model selection to its session epoch", async () => { const app = await renderApp(); const model = deferred>(); diff --git a/web/ui/app.js b/web/ui/app.js index bcff449d..53f020ea 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -214,6 +214,18 @@ function compactSummary(value, limit = 96) { return text.length > limit ? `${text.slice(0, limit - 1)}…` : text; } +function applyPromptAcceptedState(alreadySettled) { + if (alreadySettled) { + if (state.livePhase !== "running") { + state.liveRunning = false; + state.livePhase = "idle"; + } + return; + } + state.liveRunning = true; + if (state.livePhase !== "running") state.livePhase = "preparing"; +} + function relativeTime(value) { const elapsed = Date.now() - new Date(value).getTime(); if (elapsed < 60_000) return "now"; @@ -747,8 +759,7 @@ async function sendPrompt() { }); if (epoch !== state.sessionEpoch || state.promptAdmissionToken !== admissionToken) return; const alreadySettled = state.terminalPromptIds.has(receipt.id); - state.liveRunning = !alreadySettled; - state.livePhase = alreadySettled ? "idle" : "preparing"; + applyPromptAcceptedState(alreadySettled); $("prompt-input").value = ""; resizePrompt(); $("composer-hint").textContent = t("acceptedHint"); @@ -1066,8 +1077,7 @@ function applyRuntimeEvent(event) { scheduleSnapshotRefresh(); } else if (event.type === "prompt_accepted") { const alreadySettled = state.terminalPromptIds.has(event.detail?.commandId); - state.liveRunning = !alreadySettled; - state.livePhase = alreadySettled ? "idle" : "preparing"; + applyPromptAcceptedState(alreadySettled); state.liveRetry = null; renderConversation(); } else if (event.type === "agent_start") { @@ -1075,12 +1085,19 @@ function applyRuntimeEvent(event) { state.livePhase = "running"; state.liveRetry = null; renderConversation(); - } else if (event.type === "agent_settled" || event.type === "prompt_settled") { - if (event.type === "prompt_settled") rememberTerminalPrompt(event.detail?.commandId); + } else if (event.type === "agent_settled") { state.liveRunning = false; state.livePhase = "idle"; state.liveRetry = null; renderConversation(); + } else if (event.type === "prompt_settled") { + rememberTerminalPrompt(event.detail?.commandId); + if (state.livePhase !== "running") { + state.liveRunning = false; + state.livePhase = "idle"; + state.liveRetry = null; + } + renderConversation(); } else if (event.detail?.message) { if (event.detail.message.role === "user") { state.liveMessages = state.liveMessages.filter(