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/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/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/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)); 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", () => {