Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebuiDeliveryDeps, "projection" | "streamState">;
readonly enqueueMessage?: WebuiClientMessageEnqueuer;
readonly createSessionWorkspaceDir?: string;
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions packages/webui/src/client/shell/WebuiClientFoundationApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<MemoryHandoff>();
// 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<string>();
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(
Expand Down
108 changes: 108 additions & 0 deletions packages/webui/test/unit/conversation-turn-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>(() => undefined);
Expand Down Expand Up @@ -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));
Expand Down
50 changes: 50 additions & 0 deletions packages/webui/test/unit/personalization-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading