fix: keep consecutive requests running and clear finished stop state

Wait for OpenCode session cancellation before reusing a conversation, and replace the session after unconfirmed cancellation. Preserve terminal message state when stale streaming snapshots arrive so finished conversations return to Send.

Validated with full production Electron UI and the configured real text model: queued requests complete, cancellation followed by immediate resend completes, and Stop clears. Focused tests: 362 passed; typecheck and targeted lint passed. Live dispatch counting limitations are recorded in the feature evidence.

Release note: Consecutive OpenCode requests no longer inherit a previous cancellation, and finished conversations no longer retain the Stop button after stale updates.
This commit is contained in:
mesalogo
2026-09-17 10:11:31 +08:00
parent 3639566e1b
commit 94852acf82
9 changed files with 325 additions and 10 deletions
+26
View File
@@ -6053,6 +6053,11 @@ describe("App", () => {
queueItemId: secondItem.id,
prompt: secondItem.label,
});
act(() => {
agentListener?.({ requestId: run.mock.calls[1]![0].requestId, type: "done" });
});
await waitFor(() => expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument());
expect(screen.getByLabelText("发送")).toBeInTheDocument();
});
it("restores a queued message when Agent preflight rejects it", async () => {
@@ -7779,6 +7784,27 @@ describe("App", () => {
],
});
it.each(["complete", "error"] as const)("does not revive a locally %s message from a newer conversation's stale streaming snapshot", async (state) => {
const started = await startStreamingRun();
act(() => {
agentListener?.({ requestId: started.requestId, type: "text", delta: "Retained terminal output" });
agentListener?.(state === "complete"
? { requestId: started.requestId, type: "done" }
: { requestId: started.requestId, type: "error", status: "cancelled", message: "Cancelled response" });
});
await waitFor(() => expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument());
const snapshot = persistedSnapshot(started, { state: "streaming", content: "Stale partial output" });
snapshot.updatedAt = Date.now() + 10_000;
vi.mocked(api.conversations.list).mockResolvedValue([snapshot]);
const callsBeforeRefresh = vi.mocked(api.conversations.list).mock.calls.length;
await act(async () => { conversationsChangedListener?.(); });
await waitFor(() => expect(api.conversations.list).toHaveBeenCalledTimes(callsBeforeRefresh + 1));
expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument();
expect(screen.getByText("Retained terminal output")).toBeInTheDocument();
expect(screen.queryByText("Stale partial output")).not.toBeInTheDocument();
expect(screen.getByLabelText("发送")).toBeInTheDocument();
});
it("converges a streaming message onto the persisted completed state after reconnecting", async () => {
const started = await startStreamingRun();
expect(conversationsChangedListener).toBeDefined();
+5
View File
@@ -1546,6 +1546,11 @@ function mergePersistedConversations(
...conversation.messages.map((persistedMessage) => {
const localMessage = localMessageById.get(persistedMessage.id);
const message = mergeMessageImageState(persistedMessage, persistedMessage, localMessage);
// A newer conversation timestamp can still contain an older message
// snapshot while a terminal event is being persisted.
if (localMessage && localMessage.state !== "streaming" && message.state === "streaming") {
return mergeMessageImageState(localMessage, persistedMessage, localMessage);
}
if (!conversation.activeRequest && local.activeRequest?.messageId === message.id) {
return { ...message, pendingQuestions: undefined };
}