Loading files
// End-to-end tests of the chat store's actions + event-stream attachment.
// Mocks the transport via __setTransportForTesting and verifies that store
// dispatches flow through the reducer correctly. Covers the "ChatInput sends
// a message" and event-stream behaviors.
import assert from "node:assert/strict";
import test from "node:test";
import {
__resetEffortForTesting,
__setTransportForTesting,
attachChatEventStream,
cancelTurn,
createConversation,
detachChatEventStream,
dispatch,
getChatState,
getEffort,
hydrateSession,
setEffort,
resetChatStore,
setPendingViewContext,
setProject,
selectConversation,
startTurn,
} from "../../../store/chat.js";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
// Effort rides every sent message as a model-facing directive, and the default
// level ("high") is a talkative one. Pin these tests to "medium" — the level
// that deliberately says nothing — so they keep asserting what they are about.
// The directive itself has its own test at the bottom of this file.
__resetEffortForTesting("medium");
function makeMockEvents() {
const handlers = new Map();
return {
bus: {
subscribe(kind, handler) {
if (!handlers.has(kind)) handlers.set(kind, new Set());
handlers.get(kind).add(handler);
return () => handlers.get(kind).delete(handler);
},
},
emit(kind, payload) {
for (const handler of handlers.get(kind) || []) {
handler(payload);
}
},
handlerCount(kind) {
return handlers.get(kind)?.size || 0;
},
};
}
test("startTurn dispatches the user turn", async () => {
resetChatStore();
setProject("proj-1");
const calls = [];
const restore = __setTransportForTesting({
async chat_start_turn(req) {
calls.push(req);
return { turnId: "turn-42" };
},
});
try {
const result = await startTurn("make the hook land faster");
assert.deepEqual(calls, [{ projectId: "proj-1", userMessage: "make the hook land faster" }]);
assert.equal(result.turnId, "turn-42");
const state = getChatState();
assert.equal(state.currentTurnId, "turn-42");
assert.equal(state.turnInProgress, true);
assert.equal(state.history.length, 1);
assert.equal(state.history[0].role, "user");
assert.equal(state.history[0].userText, "make the hook land faster");
} finally {
restore();
resetChatStore();
}
});
test("startTurn appends the view-context note to the sent message but not the bubble", async () => {
resetChatStore();
setProject("proj-1");
dispatch({ type: "set_pending_view_context", note: "[Highlighted-view context: center.]" });
const calls = [];
const restore = __setTransportForTesting({
async chat_start_turn(req) {
calls.push(req);
return { turnId: "turn-ctx" };
},
});
try {
await startTurn("add vents here");
// Model sees text + context; the echoed user bubble keeps just the text.
assert.equal(
calls[0].userMessage,
"add vents here\n\n[Highlighted-view context: center.]",
);
const state = getChatState();
assert.equal(state.history[0].userText, "add vents here");
// Consumed on send so it can't leak into the next turn.
assert.equal(state.pendingViewContext, "");
} finally {
restore();
resetChatStore();
}
});
test("removing the last attachment clears a stale view-context note", () => {
resetChatStore();
dispatch({ type: "set_pending_view_context", note: "[ctx]" });
dispatch({ type: "add_pending_attachment", attachment: { id: "a1", name: "x.png" } });
dispatch({ type: "add_pending_attachment", attachment: { id: "a2", name: "y.png" } });
// Removing one of two attachments keeps the note (a highlight may remain).
dispatch({ type: "remove_pending_attachment", id: "a1" });
assert.equal(getChatState().pendingViewContext, "[ctx]");
// Removing the last one drops the now-orphaned note.
dispatch({ type: "remove_pending_attachment", id: "a2" });
assert.equal(getChatState().pendingViewContext, "");
resetChatStore();
});
test("startTurn injects the suggestion directive for a highlight sent with no text", async () => {
resetChatStore();
setProject("proj-1");
dispatch({ type: "set_pending_view_context", note: "[ctx]" });
const calls = [];
const restore = __setTransportForTesting({
async chat_start_turn(req) {
calls.push(req);
return { turnId: "turn-suggest" };
},
});
try {
// Empty composer + an attached highlight: the image satisfies the send guard.
await startTurn("", {
attachments: [{ name: "h.png", mediaType: "image/png", dataBase64: "AA==" }],
});
assert.match(calls[0].userMessage, /improvement options for/);
assert.match(calls[0].userMessage, /\[ctx\]/);
} finally {
restore();
resetChatStore();
}
});
test("startTurn omits the suggestion directive when the user typed an instruction", async () => {
resetChatStore();
setProject("proj-1");
dispatch({ type: "set_pending_view_context", note: "[ctx]" });
const calls = [];
const restore = __setTransportForTesting({
async chat_start_turn(req) {
calls.push(req);
return { turnId: "turn-typed" };
},
});
try {
await startTurn("add a fillet", {
attachments: [{ name: "h.png", mediaType: "image/png", dataBase64: "AA==" }],
});
assert.doesNotMatch(calls[0].userMessage, /improvement options for/);
assert.equal(calls[0].userMessage, "add a fillet\n\n[ctx]");
} finally {
restore();
resetChatStore();
}
});
test("startTurn refuses to dispatch when no project is selected", async () => {
resetChatStore();
const calls = [];
const restore = __setTransportForTesting({
async chat_start_turn(req) {
calls.push(req);
return { turnId: "x" };
},
});
try {
const result = await startTurn("hi");
assert.equal(result, null);
assert.equal(calls.length, 0);
assert.equal(getChatState().lastError, "No project selected");
} finally {
restore();
resetChatStore();
}
});
test("startTurn refuses to start a second turn while one is in progress", async () => {
resetChatStore();
setProject("proj-1");
const calls = [];
const restore = __setTransportForTesting({
async chat_start_turn(req) {
calls.push(req);
return { turnId: `turn-${calls.length}` };
},
});
try {
await startTurn("first");
const result = await startTurn("second");
assert.equal(result, null);
assert.equal(calls.length, 1);
assert.equal(getChatState().lastError, "A turn is already in progress");
} finally {
restore();
resetChatStore();
}
});
test("cancelTurn invokes the transport with the current turn id", async () => {
resetChatStore();
setProject("proj-1");
const cancels = [];
const restore = __setTransportForTesting({
async chat_start_turn() {
return { turnId: "t-7" };
},
async chat_cancel_turn(turnId) {
cancels.push(turnId);
},
});
try {
await startTurn("do the thing");
assert.equal(getChatState().turnInProgress, true);
await cancelTurn();
assert.deepEqual(cancels, ["t-7"]);
} finally {
restore();
resetChatStore();
}
});
test("setProject rehydrates history from the persisted session", async () => {
resetChatStore();
const calls = [];
const restore = __setTransportForTesting({
async chat_session_state(projectId) {
calls.push(projectId);
return {
sessionId: "s-1",
turnInProgress: false,
history: [
{ role: "user", content: "make a cup", at: 1000 },
{ role: "assistant", content: "Here is the plan.", at: 2000 },
],
};
},
});
try {
setProject("proj-h");
await tick(); // hydration is fire-and-forget
assert.deepEqual(calls, ["proj-h"]);
const state = getChatState();
assert.equal(state.currentProjectId, "proj-h");
assert.equal(state.history.length, 2);
assert.equal(state.history[0].role, "user");
assert.equal(state.history[0].userText, "make a cup");
assert.equal(state.history[1].role, "assistant");
assert.equal(state.history[1].blocks[0].text, "Here is the plan.");
assert.equal(state.isHydratingSession, false);
} finally {
restore();
resetChatStore();
}
});
test("a project lists multiple chats and opens the newest transcript", async () => {
resetChatStore();
const reads = [];
const summaries = [
{ sessionId: "s-new", title: "Fix the USB route", updatedAt: 20, messageCount: 4 },
{ sessionId: "s-old", title: "Choose the switches", updatedAt: 10, messageCount: 8 },
];
const restore = __setTransportForTesting({
async chat_session_list(projectId) {
assert.equal(projectId, "macropad");
return summaries;
},
async chat_session_read(projectId, sessionId) {
reads.push([projectId, sessionId]);
return {
sessionId,
turnInProgress: false,
history: [{ role: "user", content: sessionId, at: 1 }],
};
},
});
try {
setProject("macropad");
await tick();
assert.deepEqual(reads, [["macropad", "s-new"]]);
assert.equal(getChatState().currentSessionId, "s-new");
assert.deepEqual(getChatState().conversationSummaries, summaries);
assert.equal(getChatState().history[0].userText, "s-new");
} finally {
restore();
resetChatStore();
}
});
test("new/select chat stays inside the selected project", async () => {
resetChatStore();
const reads = [];
const restore = __setTransportForTesting({
async chat_session_state() {
return { sessionId: "legacy", turnInProgress: false, history: [] };
},
async chat_session_create(projectId) {
assert.equal(projectId, "macropad");
return { sessionId: "s-2", title: "New chat", updatedAt: 30, messageCount: 0 };
},
async chat_session_read(projectId, sessionId) {
reads.push([projectId, sessionId]);
return {
sessionId,
turnInProgress: false,
history: [{ role: "user", content: "older chat", at: 1 }],
};
},
});
try {
setProject("macropad");
await tick();
const created = await createConversation();
assert.equal(created.sessionId, "s-2");
assert.equal(getChatState().currentProjectId, "macropad");
assert.equal(getChatState().currentSessionId, "s-2");
dispatch({
type: "set_conversation_summaries",
projectId: "macropad",
summaries: [created, { sessionId: "s-1", title: "Old", updatedAt: 1, messageCount: 1 }],
});
assert.equal(await selectConversation("s-1"), true);
assert.deepEqual(reads, [["macropad", "s-1"]]);
assert.equal(getChatState().history[0].userText, "older chat");
} finally {
restore();
resetChatStore();
}
});
test("setProject marks the active project as hydrating until transcript load settles", async () => {
resetChatStore();
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const restore = __setTransportForTesting({
async chat_session_state() {
await gate;
return { sessionId: "empty", turnInProgress: false, history: [] };
},
});
try {
setProject("proj-loading");
assert.equal(getChatState().isHydratingSession, true);
release();
await tick();
assert.equal(getChatState().isHydratingSession, false);
} finally {
restore();
resetChatStore();
}
});
test("setProject skips hydration when the project is unchanged or cleared", async () => {
resetChatStore();
const calls = [];
const restore = __setTransportForTesting({
async chat_session_state(projectId) {
calls.push(projectId);
return { sessionId: "s", turnInProgress: false, history: [] };
},
});
try {
setProject("proj-1");
await tick();
setProject("proj-1"); // same project → reducer no-op, no refetch
setProject(""); // clearing → no fetch
await tick();
assert.deepEqual(calls, ["proj-1"]);
} finally {
restore();
resetChatStore();
}
});
test("hydrateSession does not clobber a turn that started meanwhile", async () => {
resetChatStore();
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const restore = __setTransportForTesting({
async chat_session_state() {
await gate;
return {
sessionId: "s",
turnInProgress: false,
history: [{ role: "user", content: "stale", at: 1 }],
};
},
async chat_start_turn() {
return { turnId: "t-1" };
},
});
try {
setProject("proj-x"); // kicks off hydrateSession, blocked on the gate
await startTurn("new message"); // sets turnInProgress + a live user turn
release();
await tick();
const state = getChatState();
// Hydration must have bailed: the live turn stands, the stale row is gone.
assert.equal(state.turnInProgress, true);
assert.equal(state.history.length, 1);
assert.equal(state.history[0].userText, "new message");
} finally {
restore();
resetChatStore();
}
});
test("attachChatEventStream subscribes and pipes events into the reducer", () => {
resetChatStore();
const events = makeMockEvents();
const restore = __setTransportForTesting({ events: events.bus });
try {
const detach = attachChatEventStream();
assert.equal(events.handlerCount("chat_event"), 1);
events.emit("chat_event", { kind: "turn_start", turnId: "t-9" });
events.emit("chat_event", { kind: "text_delta", turnId: "t-9", text: "hi" });
events.emit("chat_event", { kind: "turn_end", turnId: "t-9" });
const state = getChatState();
assert.equal(state.history.length, 1);
assert.equal(state.history[0].role, "assistant");
assert.equal(state.history[0].blocks[0].text, "hi");
assert.equal(state.turnInProgress, false);
detach();
assert.equal(events.handlerCount("chat_event"), 0);
} finally {
detachChatEventStream();
restore();
resetChatStore();
}
});
test("attachChatEventStream is idempotent — second call replaces first", () => {
resetChatStore();
const events = makeMockEvents();
const restore = __setTransportForTesting({ events: events.bus });
try {
attachChatEventStream();
attachChatEventStream();
assert.equal(events.handlerCount("chat_event"), 1);
} finally {
detachChatEventStream();
restore();
resetChatStore();
}
});
// The pill's real lever. `app_set_effort` persists the level and the driver
// spends it as the CLI's `--effort`; before this call existed on the client,
// the pill could read "Max" while every turn ran at the CLI's default, because
// only the prompt directive was leaving the browser.
test("picking a level hands it to the server, and a server that refuses does not eat the click", async () => {
const posted = [];
const restore = __setTransportForTesting({
async app_set_effort(level) {
posted.push(level);
return {};
},
});
try {
assert.equal(setEffort("xhigh"), "xhigh");
await tick();
assert.deepEqual(posted, ["xhigh"]);
// An unknown level is normalised before it is stored *or* sent — the CLI
// never sees a word it does not have.
assert.equal(setEffort("turbo"), "high");
await tick();
assert.deepEqual(posted, ["xhigh", "high"]);
} finally {
restore();
__resetEffortForTesting("medium");
}
const rejecting = __setTransportForTesting({
async app_set_effort() {
throw new Error("server is down");
},
});
try {
assert.equal(setEffort("low"), "low", "the pick still applies locally");
await tick();
assert.equal(getEffort(), "low");
} finally {
rejecting();
__resetEffortForTesting("medium");
}
});
test("the effort directive rides the sent message, never the echoed bubble", async () => {
resetChatStore();
setProject("proj-1");
__resetEffortForTesting("max");
const calls = [];
const restore = __setTransportForTesting({
async chat_start_turn(req) {
calls.push(req);
return { turnId: "turn-effort" };
},
});
try {
await startTurn("shrink the board");
const sent = calls[0].userMessage;
assert.ok(sent.startsWith("shrink the board"), "the user's own words come first");
assert.match(sent, /\[Effort: max — ultrathink\./);
// The bubble is what the user sees; a hidden directive must stay hidden.
assert.equal(getChatState().history[0].userText, "shrink the board");
} finally {
restore();
__resetEffortForTesting("medium");
resetChatStore();
}
});
test("effort is last, after the view-context note — it modifies the request, it is not the request", async () => {
resetChatStore();
setProject("proj-1");
__resetEffortForTesting("high");
setPendingViewContext("[Viewer context: pcb view of board main]");
const calls = [];
const restore = __setTransportForTesting({
async chat_start_turn(req) {
calls.push(req);
return { turnId: "turn-order" };
},
});
try {
await startTurn("move U3");
const sent = calls[0].userMessage;
assert.ok(sent.indexOf("Viewer context") < sent.indexOf("[Effort:"), "context precedes effort");
assert.ok(sent.startsWith("move U3"));
} finally {
restore();
__resetEffortForTesting("medium");
resetChatStore();
}
});
test("medium adds nothing at all to the sent message", async () => {
resetChatStore();
setProject("proj-1");
__resetEffortForTesting("medium");
const calls = [];
const restore = __setTransportForTesting({
async chat_start_turn(req) {
calls.push(req);
return { turnId: "turn-quiet" };
},
});
try {
await startTurn("add a test point");
assert.equal(calls[0].userMessage, "add a test point");
} finally {
restore();
resetChatStore();
}
});
// The question card's delegate button sends a paragraph written for the model
// ("Build the best version — you decide every preference above…"). Echoed
// verbatim it reads as the user having typed a strange formal instruction.
test("startTurn can echo a different sentence than it sends", async () => {
resetChatStore();
setProject("proj-1");
const calls = [];
const restore = __setTransportForTesting({
async chat_start_turn(req) {
calls.push(req);
return { turnId: "turn-echo" };
},
});
try {
await startTurn("Build the best version — you decide every preference above.", {
echoAs: "You decide — pick the best of each.",
});
assert.match(calls[0].userMessage, /Build the best version/);
assert.equal(getChatState().history[0].userText, "You decide — pick the best of each.");
} finally {
restore();
resetChatStore();
}
});
62869109eThis file is pinned to the selected release. Download the original to open it in your design tool.