From 498efa8e22b31b374289ee2f38e2904c91a6aea2 Mon Sep 17 00:00:00 2001 From: probe Date: Sat, 24 Oct 2026 08:55:27 +0800 Subject: [PATCH 1/2] fix(webui): clear the sending flag on the session a first message created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A first message on the home surface creates the session it is sent to, and the turn's record moves onto it. The turn's own cleanup read the live slice through the key the turn started from, so it looked at the home slot the record had already left, decided the turn no longer owned the stream, and skipped the cleanup that clears the sending flag. The flag therefore stayed standing on the created session. The composer's send button remained a stop button — which swallows the click that was meant to send — and the message after the first one was admitted as a queued one instead of being sent. The reader is now asked for the key the turn's record is filed under, which is the created session's once the first message has created it. The key travels with the submission, so the read follows the same move the writer's own writes already followed. Two cases in the unit suite pin it: the flag is cleared on the created session, and the message after the first one is sent rather than queued. Both fail on the trunk with the symptoms above. --- .../turn/application/turn-commands.ts | 35 +++++- .../unit/conversation-turn-commands.test.ts | 108 ++++++++++++++++++ 2 files changed, 137 insertions(+), 6 deletions(-) diff --git a/packages/webui/src/client/features/turn/application/turn-commands.ts b/packages/webui/src/client/features/turn/application/turn-commands.ts index 2bbe04c1e..40d20c51c 100644 --- a/packages/webui/src/client/features/turn/application/turn-commands.ts +++ b/packages/webui/src/client/features/turn/application/turn-commands.ts @@ -282,8 +282,15 @@ export interface WebuiSendTurnArgs { readonly intent: WebuiSubmissionIntent; /** Decided by the owner before any work, so the ticket can say it immediately. */ readonly admission: WebuiSubmissionAdmission; - /** Read the live slice. The home→session migration below changes the key. */ - readonly readStream?: () => WebuiStreamState; + /** + * Read the live slice of a record, by the key it is filed under. + * + * The key is passed in rather than remembered because a first message creates + * the session it is sent to: the record moves onto that session, and a reader + * bound to the key the turn started from answers about a slot the record has + * already left. + */ + readonly readStream?: (sessionKey: string) => WebuiStreamState; readonly deps: Omit; readonly enqueueMessage?: WebuiClientMessageEnqueuer; readonly createSessionWorkspaceDir?: string; @@ -336,12 +343,25 @@ export function sendWebuiTurn( // The bundle is woven here, from the writer this command owns: `createSink` // reads the *current* writer, so a turn whose first message created the // session mints its lease against the migrated key rather than the home slot. + const readSessionStream = args.readStream; const turnHandlers: WebuiComposerSubmitHandlers = buildWebuiComposerHandlers({ createSink: () => writer.createSink(), resetStreamForTurn: () => writer.resetStreamForTurn(), setRefusal: (refusal) => writer.setStreamRefusal(refusal), setSending: (sending) => writer.setTurnSending(sending), - ...(args.readStream ? { readStream: args.readStream } : {}), + // The live slice, read through the key the turn's record lives under. A + // first message creates its session and the record moves onto it, so the + // key the turn started from is not the key the record is on any more — and + // a reader left on the old key would decide the turn no longer owns the + // stream, which is what strands its sending flag. + ...(readSessionStream + ? { + readStream: () => + readSessionStream( + createdSessionId ?? args.sessionId ?? WEBUI_HOME_SESSION_KEY, + ), + } + : {}), // The draft is consumed by the submission, through the link the assembly // installed. No caller supplies a writer. // @@ -447,9 +467,12 @@ export function createWebuiConversationTurnCommands( setSending: viewWriter.setSending, }); - /** The live slice. The home→session migration changes which key this reads. */ + /** The live slice of a record, read by the key it is filed under. */ + const readSessionStream = (sessionKey: string): WebuiStreamState => + store.readSession(sessionKey).stream; + /** The live slice of this owner's record. */ const readStream = (): WebuiStreamState => - store.readSession(sessionId ?? WEBUI_HOME_SESSION_KEY).stream; + readSessionStream(sessionId ?? WEBUI_HOME_SESSION_KEY); /** * The loop's transports, cut from the slice: an unwired operation is absent, @@ -595,7 +618,7 @@ export function createWebuiConversationTurnCommands( // The UI submits intent only. The execution owner reads the canonical // target at invocation time so a stale render cannot choose send vs queue. sending: admission.turnLive && decision.kind !== "unsupported" && decision.kind !== "needs-session", - readStream, + readStream: readSessionStream, deps: loopDeps(hold), ...(capabilities?.enqueue ? { enqueueMessage: capabilities.enqueue } : {}), ...(intent.createSessionWorkspaceDir diff --git a/packages/webui/test/unit/conversation-turn-commands.test.ts b/packages/webui/test/unit/conversation-turn-commands.test.ts index e7dd17464..eb367c254 100644 --- a/packages/webui/test/unit/conversation-turn-commands.test.ts +++ b/packages/webui/test/unit/conversation-turn-commands.test.ts @@ -16,6 +16,7 @@ import { createWebuiSessionStore } from "../../src/client/application/session-st import { createWebuiSessionScopeManager } from "../../src/client/conversation/session-scope.js"; import { createWebuiDeliveryDisposers } from "../../src/client/features/turn/application/delivery-disposers.js"; import { WEBUI_HOME_SESSION_KEY } from "../../src/client/application/state.js"; +import type { WebuiStreamFrame } from "../../src/shared/contracts/stream.js"; /** A resume that never settles: the attachment's lease stays claimed. */ const openStream = () => new Promise(() => undefined); @@ -169,6 +170,113 @@ describe("the conversation turn commands", () => { }); }); +describe("a first message on the home surface", () => { + const createSession = async () => ({ sessionId: "s-created" }); + + it("clears the sending flag on the session the message created", async () => { + // The first message has no session yet, so the turn creates one and its + // record moves onto it. The flag the composer renders from lives on that + // record, and the turn's own cleanup has to read it there: a reader still + // looking at the home slot would decide the turn no longer owns the + // stream, skip the cleanup, and leave the send button as a stop button — + // which swallows the next message into the queue. + const store = createWebuiSessionStore(); + const commands = createWebuiConversationTurnCommands({ + store, + sessionId: undefined, + capabilities: { + createSession: createSession as never, + send: (async ( + _request: unknown, + onFrame: (frame: WebuiStreamFrame) => void, + ) => { + onFrame({ + cursor: "c1", + dataJson: JSON.stringify({ + type: 2, + agent_message: { msg_id: "m1", msg_content: "hi" }, + }), + }); + onFrame({ cursor: "c2", dataJson: "[DONE]" }); + }) as never, + }, + taskSessions: { + creationPending: () => false, + submit: async () => { + // The creation moves the home record onto the session it created, + // so the home slot is empty by the time the turn settles. + store.transferSession(WEBUI_HOME_SESSION_KEY, "s-created", { + expectSourceIncarnation: store.readHomeIncarnation(), + }); + return { + kind: "created" as const, + sessionId: "s-created", + selected: true, + }; + }, + }, + }); + + const ticket = commands.send({ message: "hello" }); + await ticket?.completion; + + expect(store.readSession("s-created").sending).toBe(false); + expect(store.readSession("s-created").stream.phase).toBe("done"); + }); + + it("sends the next message instead of queueing it", async () => { + // The same defect, seen from the composer: a sending flag left standing + // makes the next submission look like a turn that is already live, so it + // is queued rather than sent. + const store = createWebuiSessionStore(); + const enqueueMessage = vi.fn(async () => undefined); + const sendMessage = vi.fn( + async ( + _request: unknown, + onFrame: (frame: WebuiStreamFrame) => void, + ) => { + onFrame({ cursor: "c1", dataJson: "[DONE]" }); + }, + ); + const capabilities = { + createSession: createSession as never, + send: sendMessage as never, + enqueue: enqueueMessage as never, + }; + const taskSessions = { + creationPending: () => false, + submit: async () => ({ + kind: "created" as const, + sessionId: "s-created", + selected: true, + }), + }; + const home = createWebuiConversationTurnCommands({ + store, + sessionId: undefined, + capabilities, + taskSessions, + }); + + await home.send({ message: "first" })?.completion; + + // The created session is the one the user is now in, so the next message + // is sent from it. + const inSession = createWebuiConversationTurnCommands({ + store, + sessionId: "s-created", + capabilities, + taskSessions, + }); + const second = inSession.send({ message: "second" }); + + expect(second?.admission).toEqual({ kind: "dispatching" }); + await second?.completion; + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(enqueueMessage).not.toHaveBeenCalled(); + }); +}); + describe("recheck and gap recovery are the command set's, not the view's", () => { const busy = { turnId: "t-1", busyReason: "turn" as const, locallyOwned: false }; const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); From 9a825f36df70a7566fe80b7aec4ebad2ab975d2a Mon Sep 17 00:00:00 2001 From: probe Date: Sat, 24 Oct 2026 08:55:38 +0800 Subject: [PATCH 2/2] fix(webui): type the memory hand-off's line once, not on every visit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「在会话中创建」 on 记忆摘要 opens the home composer with the memory file attached and one line already typed. The line was written by an effect that watched the selected session, and the hand-off was never cleared, so every later visit to the home surface — a session, a back, a new task, a deleted session — ran the effect again and replaced whatever the user had written with "我想调整下这个记忆文件". The hand-off is one-shot, and so is everything it brings. The file it attaches is held by the composer's consumed seed token; the line is now held by a latch of the same kind. The decision is a pure function next to the hand-off's own contract, and the shell keeps the token of the hand-off whose line it has typed. The line is still typed when the hand-off opens the home surface, and it is still typed into the home slot rather than the one the shell considered current when the hand-off was made. --- .../settings/contracts/memory-handoff.ts | 37 ++++++++++++++ .../client/shell/WebuiClientFoundationApp.tsx | 22 ++++++-- .../unit/personalization-settings.test.tsx | 50 +++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/packages/webui/src/client/features/settings/contracts/memory-handoff.ts b/packages/webui/src/client/features/settings/contracts/memory-handoff.ts index 3a6e59b1b..641efd509 100644 --- a/packages/webui/src/client/features/settings/contracts/memory-handoff.ts +++ b/packages/webui/src/client/features/settings/contracts/memory-handoff.ts @@ -28,6 +28,43 @@ export interface MemoryHandoff { readonly content: string; } +/** + * What the hand-off still owes the composer. + * + * The hand-off is one-shot, and so is everything it brings: the file it + * attaches and the line it types beside it. The file's own latch is the + * composer's consumed seed token; this is the same latch for the line, asked + * the same way — with the token the caller has already typed. + * + * It has to be a latch rather than a write. The line is typed when the + * hand-off opens the home surface, but the surface is visited again for every + * reason there is — a session, a back, a new task — and a line typed on each + * visit replaces what the user has written since. + */ +export type WebuiMemoryHandoffPrompt = + /** Nothing to type: no hand-off, or this token has already been typed. */ + | { readonly kind: "none" } + /** Type this line, and record the token before doing it. */ + | { readonly kind: "prompt"; readonly token: string; readonly prompt: string }; + +/** + * Read the hand-off's line, as a decision rather than as a write. + * + * The caller records the token before it types, so the order the invariant + * requires is visible at the call site rather than implied by this function's + * internals. + */ +export function readWebuiMemoryHandoffPrompt(input: { + readonly handoff: MemoryHandoff | undefined; + /** The token whose line has already been typed, if any. */ + readonly consumedToken: string | undefined; +}): WebuiMemoryHandoffPrompt { + const { handoff } = input; + if (!handoff) return { kind: "none" }; + if (input.consumedToken === handoff.token) return { kind: "none" }; + return { kind: "prompt", token: handoff.token, prompt: MEMORY_HANDOFF_PROMPT }; +} + /** * The save predicate, as a decision rather than as component state. * diff --git a/packages/webui/src/client/shell/WebuiClientFoundationApp.tsx b/packages/webui/src/client/shell/WebuiClientFoundationApp.tsx index 74e17733f..762708739 100644 --- a/packages/webui/src/client/shell/WebuiClientFoundationApp.tsx +++ b/packages/webui/src/client/shell/WebuiClientFoundationApp.tsx @@ -19,7 +19,7 @@ import { Composer } from "../features/composer/ui/Composer.js"; import { LeftRail } from "../features/sessions/ui/LeftRail.js"; import { Transcript } from "../features/transcript/ui/Transcript.js"; import { UserMenu } from "../features/account/ui/UserMenu.js"; -import { MEMORY_HANDOFF_PROMPT, type MemoryHandoff } from "../features/settings/contracts/memory-handoff.js"; +import { readWebuiMemoryHandoffPrompt, type MemoryHandoff } from "../features/settings/contracts/memory-handoff.js"; import { WebuiWorkspacePanel } from "../features/workspace/ui/WorkspacePanels.js"; import { WebuiProgressOverviewPanel } from "../features/workspace/ui/WorkspaceProgressPanel.js"; import { @@ -558,15 +558,31 @@ export function WebuiClientFoundationApp( * current, so starting from a session would type it somewhere the user never * sees. Once no session is selected the key is the home slot, which is the * slot on the home surface. + * + * It is written **once**. The home surface is visited again for every + * reason there is — a session, a back, a new task — and a line typed on each + * visit replaces what the user has written since. The hand-off's token is + * the latch, the same token the attached file is held by. */ const [memoryHandoff, setMemoryHandoff] = useState(); + // The token of the hand-off whose line has been typed. A ref, not state: the + // latch is a fact about what has been written, not something to render. + const handoffPromptToken = useRef(); const createMemorySession = useCallback((input: MemoryHandoff) => { setMemoryHandoff(input); startNewTask(); }, []); useEffect(() => { - if (!memoryHandoff || selectedSessionId) return; - setDraft(MEMORY_HANDOFF_PROMPT); + if (selectedSessionId) return; + const decision = readWebuiMemoryHandoffPrompt({ + handoff: memoryHandoff, + consumedToken: handoffPromptToken.current, + }); + if (decision.kind === "none") return; + // Recorded before the line is typed, so a re-render that runs this effect + // again cannot type it a second time. + handoffPromptToken.current = decision.token; + setDraft(decision.prompt); }, [memoryHandoff, selectedSessionId, setDraft]); const handleWorkspaceSubagentClick = useCallback( diff --git a/packages/webui/test/unit/personalization-settings.test.tsx b/packages/webui/test/unit/personalization-settings.test.tsx index 206393320..b3831a4e3 100644 --- a/packages/webui/test/unit/personalization-settings.test.tsx +++ b/packages/webui/test/unit/personalization-settings.test.tsx @@ -41,6 +41,7 @@ import { MEMORY_HANDOFF_PROMPT, formatMemoryTimestamp, isMemorySaveable, + readWebuiMemoryHandoffPrompt, } from "../../src/client/features/settings/contracts/memory-handoff.js"; import { resolveEditorSeed } from "../../src/client/features/settings/ui/GlobalInstructionsSection.js"; import { isProfileSaveable } from "../../src/client/features/settings/ui/UserProfileSection.js"; @@ -172,6 +173,55 @@ describe("「在会话中创建」 hand-off", () => { // the file identity. expect(MEMORY_HANDOFF_PROMPT).toBe("我想调整下这个记忆文件"); }); + + it("types the line once, not on every visit to the home surface", () => { + const handoff = { + token: "t1", + fileName: "memory.md", + mimeType: "text/markdown", + sizeBytes: 12, + content: "x", + }; + // The first visit types it. + expect(readWebuiMemoryHandoffPrompt({ handoff, consumedToken: undefined })).toEqual({ + kind: "prompt", + token: "t1", + prompt: MEMORY_HANDOFF_PROMPT, + }); + // The second visit must not. The home slot holds what the user has + // written since, and a line typed again replaces it. + expect(readWebuiMemoryHandoffPrompt({ handoff, consumedToken: "t1" })).toEqual({ + kind: "none", + }); + // A hand-off of its own still types, whatever was typed before. + expect( + readWebuiMemoryHandoffPrompt({ + handoff: { ...handoff, token: "t2" }, + consumedToken: "t1", + }), + ).toEqual({ kind: "prompt", token: "t2", prompt: MEMORY_HANDOFF_PROMPT }); + // No hand-off, nothing to type. + expect( + readWebuiMemoryHandoffPrompt({ handoff: undefined, consumedToken: undefined }), + ).toEqual({ kind: "none" }); + }); + + it("keeps the shell from typing the line a second time", () => { + // The decision is only half of the fix: the shell has to ask it, and has + // to keep the token it typed. An effect cannot be reached by a static + // render — this package has no DOM framework — so the wiring is read off + // the source, the way the rail's own wiring is. + const frame = readFileSync( + new URL("../../src/client/shell/WebuiClientFoundationApp.tsx", import.meta.url), + "utf8", + ); + expect(frame).toMatch(/readWebuiMemoryHandoffPrompt\(\{/u); + expect(frame).toMatch(/consumedToken: handoffPromptToken\.current/u); + expect(frame).toMatch(/handoffPromptToken\.current = decision\.token/u); + // A direct write of the constant is what put the line back into the slot + // on every visit; it must not come back. + expect(frame).not.toMatch(/setDraft\(MEMORY_HANDOFF_PROMPT\)/u); + }); }); describe("the delete confirmation's copy", () => {