Files
multica/packages/core/chat/store.test.ts
Jiayuan Zhang a3fe6d91dd MUL-5150: add project context to Chat (#5765)
* feat(chat): add project context

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): resolve MUL-5150 review blockers

- Renumber project-context migrations to unique prefixes after current main:
  206_chat_session_project -> 212 (column), 207_chat_session_project_index ->
  213 (concurrent index). 206/207 collided with 206_agent_disabled_runtime_skills
  and main's 207-211 client_usage_daily set.
- Add the 4 missing chat input.project_context keys to ja/ko locales so the
  locale parity test passes (en/zh-Hans already had them).
- Lock the project-context control while a send is in flight (isSubmitting),
  not just while the agent is running. A brand-new chat creates its session
  lazily during send bound to the project at click time; switching project
  mid-send would create the session against the stale project and clear the
  editor as if the send landed on the new selection. Add a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): complete project context handling

* fix(chat): pin fresh chat to open session's agent on project switch

Switching an existing session to a different project opens a fresh chat but
only cleared the active session, dropping selection back to the stored
`selectedAgentId`. When that preference was stale (open session belongs to
agent B while the persisted pick is still agent A), the lazily-created session
and its first send bound to the wrong agent (agent A).

Extract the project-switch decision into a shared `planProjectContextChange`
pure helper in use-chat-controller.ts and route both chat surfaces (the chat
tab controller and the floating ChatWindow) through it, so the fresh chat is
pinned to the open session's agent and the rule cannot drift between the two
copies. Add a dual-entry regression test (pure-fn guard + controller
integration) covering the stale selectedAgentId case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* chore(ci): re-trigger required checks on latest head

The prior push updated the branch ref but GitHub did not emit a pull_request
synchronize for it (PR head-sync lag), so CI/Mobile Verify never ran on the
commit carrying the stale-agent project-switch fix. Empty commit to force a
fresh synchronize on a head that includes it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): renumber project migrations to 213/214 after main added 212

Current main added 212_agent_service_tier; the PR's 212/213 chat migrations
collided with it on the merge ref, failing TestMigrationNumericPrefixesStay
UniqueAfterLegacySet. Merge current main and move the chat column migration to
213 and the concurrent index migration to 214 (column before index preserved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): lock ProjectPicker clear control during send (keyboard path)

The send-pending lock only put pointer-events-none on the wrapper, which
blocks the mouse but leaves ProjectPicker's inline clear button in the tab
order — a keyboard user could Tab to "Remove from project" and press Enter
mid-send, detaching the project after the lazily-created session already went
out with the old one (reopens the mid-send retarget path via keyboard).

Add an explicit `disabled` capability to the shared ProjectPicker that locks
the trigger, the menu (forced closed), and the inline clear button (disabled +
out of the tab order). Defaults to false, so issue/create/autopilot callers
keep their hover/keyboard clear. ChatInput passes disabled while the project
selection is locked.

Tests: real-ProjectPicker regression (keyboard activation of the clear control
is inert when disabled; still works when enabled) + ChatInput wiring assertion
that the picker is disabled mid-send.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
2026-07-24 11:30:27 +08:00

305 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { createChatStore, DRAFT_NEW_SESSION } from "./store";
import type { StorageAdapter } from "../types";
import type { Attachment } from "../types";
function memStorage(): StorageAdapter {
const m = new Map<string, string>();
return {
getItem: (k) => m.get(k) ?? null,
setItem: (k, v) => {
m.set(k, v);
},
removeItem: (k) => {
m.delete(k);
},
};
}
function makeAttachment(id: string): Attachment {
return {
id,
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
filename: `${id}.png`,
url: `/uploads/${id}.png`,
download_url: `/api/attachments/${id}/download`,
markdown_url: `/api/attachments/${id}/download`,
content_type: "image/png",
size_bytes: 1,
created_at: new Date(0).toISOString(),
};
}
// The pre-MUL-4864 scheme kept one new-chat draft per agent, in `__new__:<id>`
// slots. Those slots have no timestamp, so on upgrade only one can survive:
// the one for the agent the workspace has selected — the draft the user would
// have been shown. The rest were the invisible multi-draft state, and go.
describe("chat store — legacy per-agent new-chat draft migration", () => {
const DRAFTS_KEY = "multica:chat:drafts";
const ATTACHMENTS_KEY = "multica:chat:draft-attachments";
const AGENT_KEY = "multica:chat:selectedAgentId";
it("adopts the selected agent's legacy draft into the single new-chat slot", () => {
const storage = memStorage();
storage.setItem(AGENT_KEY, "agent-1");
storage.setItem(
DRAFTS_KEY,
JSON.stringify({ "__new__:agent-1": "mine", "__new__:agent-2": "other" }),
);
const store = createChatStore({ storage });
expect(store.getState().inputDrafts).toEqual({ [DRAFT_NEW_SESSION]: "mine" });
});
it("migrates the matching attachments with the text, not another agent's", () => {
const storage = memStorage();
storage.setItem(AGENT_KEY, "agent-1");
storage.setItem(DRAFTS_KEY, JSON.stringify({ "__new__:agent-1": "mine" }));
storage.setItem(
ATTACHMENTS_KEY,
JSON.stringify({
"__new__:agent-1": [makeAttachment("att-mine")],
"__new__:agent-2": [makeAttachment("att-other")],
}),
);
const store = createChatStore({ storage });
expect(store.getState().inputDraftAttachments[DRAFT_NEW_SESSION]?.map((a) => a.id)).toEqual([
"att-mine",
]);
expect(store.getState().inputDraftAttachments["__new__:agent-2"]).toBeUndefined();
});
it("persists the migration so the legacy slots do not come back on reload", () => {
const storage = memStorage();
storage.setItem(AGENT_KEY, "agent-1");
storage.setItem(
DRAFTS_KEY,
JSON.stringify({ "__new__:agent-1": "mine", "__new__:agent-2": "other" }),
);
createChatStore({ storage });
// A second store reads what the first one wrote — this is the reload.
const reloaded = createChatStore({ storage });
expect(JSON.parse(storage.getItem(DRAFTS_KEY) ?? "{}")).toEqual({ [DRAFT_NEW_SESSION]: "mine" });
expect(reloaded.getState().inputDrafts).toEqual({ [DRAFT_NEW_SESSION]: "mine" });
});
it("drops every legacy slot when no agent is selected", () => {
const storage = memStorage();
storage.setItem(DRAFTS_KEY, JSON.stringify({ "__new__:agent-1": "a", "__new__:agent-2": "b" }));
const store = createChatStore({ storage });
expect(store.getState().inputDrafts).toEqual({});
expect(storage.getItem(DRAFTS_KEY)).toBeNull();
});
it("leaves real session drafts untouched", () => {
const storage = memStorage();
storage.setItem(AGENT_KEY, "agent-1");
storage.setItem(
DRAFTS_KEY,
JSON.stringify({ "session-a": "draft A", "session-b": "draft B", "__new__:agent-1": "mine" }),
);
const store = createChatStore({ storage });
expect(store.getState().inputDrafts).toEqual({
"session-a": "draft A",
"session-b": "draft B",
[DRAFT_NEW_SESSION]: "mine",
});
});
it("keeps a current-scheme draft rather than overwriting it with a legacy one", () => {
const storage = memStorage();
storage.setItem(AGENT_KEY, "agent-1");
storage.setItem(
DRAFTS_KEY,
JSON.stringify({ [DRAFT_NEW_SESSION]: "current", "__new__:agent-1": "stale" }),
);
const store = createChatStore({ storage });
expect(store.getState().inputDrafts).toEqual({ [DRAFT_NEW_SESSION]: "current" });
});
it("does not touch storage when there is nothing to migrate", () => {
const storage = memStorage();
storage.setItem(DRAFTS_KEY, JSON.stringify({ [DRAFT_NEW_SESSION]: "typed" }));
const before = storage.getItem(DRAFTS_KEY);
createChatStore({ storage });
expect(storage.getItem(DRAFTS_KEY)).toBe(before);
});
});
describe("chat store — open/closed default", () => {
it("starts closed when no preference is stored", () => {
const store = createChatStore({ storage: memStorage() });
expect(store.getState().isOpen).toBe(false);
});
it("honours an explicit stored 'open' preference", () => {
const storage = memStorage();
storage.setItem("multica:chat:isOpen", "true");
const store = createChatStore({ storage });
expect(store.getState().isOpen).toBe(true);
});
it("persists a toggle so the choice survives reload", () => {
const storage = memStorage();
const store = createChatStore({ storage });
store.getState().setOpen(true);
expect(storage.getItem("multica:chat:isOpen")).toBe("true");
const reloaded = createChatStore({ storage });
expect(reloaded.getState().isOpen).toBe(true);
});
});
describe("chat store — selected project", () => {
it("persists and clears the next chat's project per workspace", () => {
const storage = memStorage();
const store = createChatStore({ storage });
store.getState().setSelectedProjectId("project-1");
expect(storage.getItem("multica:chat:selectedProjectId")).toBe("project-1");
expect(createChatStore({ storage }).getState().selectedProjectId).toBe("project-1");
store.getState().setSelectedProjectId(null);
expect(storage.getItem("multica:chat:selectedProjectId")).toBeNull();
});
});
describe("chat store — draft attachments", () => {
let store: ReturnType<typeof createChatStore>;
beforeEach(() => {
store = createChatStore({ storage: memStorage() });
});
it("deduplicates attachment drafts by id", () => {
store.getState().addInputDraftAttachment("draft-1", makeAttachment("att-1"));
store.getState().addInputDraftAttachment("draft-1", {
...makeAttachment("att-1"),
filename: "updated.png",
});
expect(store.getState().inputDraftAttachments["draft-1"]).toHaveLength(1);
expect(store.getState().inputDraftAttachments["draft-1"]?.[0]?.filename).toBe("updated.png");
});
it("clearInputDraft clears both text and attachment records", () => {
store.getState().setInputDraft("draft-1", "hello");
store.getState().addInputDraftAttachment("draft-1", makeAttachment("att-1"));
store.getState().clearInputDraft("draft-1");
expect(store.getState().inputDrafts["draft-1"]).toBeUndefined();
expect(store.getState().inputDraftAttachments["draft-1"]).toBeUndefined();
});
});
describe("chat store — floating window preference", () => {
it("defaults ON when no preference is stored", () => {
const store = createChatStore({ storage: memStorage() });
expect(store.getState().floatingChatEnabled).toBe(true);
});
it("honours an explicit stored 'false' preference (opt-out)", () => {
const storage = memStorage();
storage.setItem("multica:chat:floatingChatEnabled", "false");
const store = createChatStore({ storage });
expect(store.getState().floatingChatEnabled).toBe(false);
});
it("honours an explicit stored 'true' preference", () => {
const storage = memStorage();
storage.setItem("multica:chat:floatingChatEnabled", "true");
const store = createChatStore({ storage });
expect(store.getState().floatingChatEnabled).toBe(true);
});
it("persists an enable, then collapses an open overlay when disabled again", () => {
const storage = memStorage();
storage.setItem("multica:chat:floatingChatEnabled", "true");
storage.setItem("multica:chat:isOpen", "true");
const store = createChatStore({ storage });
expect(store.getState().floatingChatEnabled).toBe(true);
expect(store.getState().isOpen).toBe(true);
store.getState().setFloatingChatEnabled(false);
expect(store.getState().floatingChatEnabled).toBe(false);
expect(store.getState().isOpen).toBe(false);
expect(storage.getItem("multica:chat:floatingChatEnabled")).toBe("false");
// A fresh store rehydrates the persisted preference.
const reopened = createChatStore({ storage });
expect(reopened.getState().floatingChatEnabled).toBe(false);
store.getState().setFloatingChatEnabled(true);
expect(store.getState().floatingChatEnabled).toBe(true);
expect(storage.getItem("multica:chat:floatingChatEnabled")).toBe("true");
});
});
// The ledger is what makes a durable draft restore (#5219) apply at most once.
// A consume request can be lost — retries exhausted, app closed mid-flight — and
// the row then comes back on the next fetch. Without a record that survives the
// reload, the prompt would be restored into the composer a second time, after
// the user has already sent it.
describe("chat store — applied draft-restore ledger", () => {
it("survives a reload so a lost consume cannot re-offer the restore", () => {
const storage = memStorage();
const store = createChatStore({ storage });
store.getState().markDraftRestoreApplied("restore-1");
expect(store.getState().appliedDraftRestoreIds).toEqual(["restore-1"]);
const reloaded = createChatStore({ storage });
expect(reloaded.getState().appliedDraftRestoreIds).toEqual(["restore-1"]);
});
it("is idempotent and drops the entry once the row is confirmed gone", () => {
const store = createChatStore({ storage: memStorage() });
store.getState().markDraftRestoreApplied("restore-1");
store.getState().markDraftRestoreApplied("restore-1");
expect(store.getState().appliedDraftRestoreIds).toEqual(["restore-1"]);
store.getState().forgetDraftRestoreApplied("restore-1");
expect(store.getState().appliedDraftRestoreIds).toEqual([]);
});
// Every entry in here is an unconfirmed consume: its row is still on the
// server. Evicting one to cap the ledger would re-arm the restore it was
// suppressing — the next fetch offers an already-applied prompt again and the
// user can send it twice. Only server confirmation may compact this.
it("never evicts an unconfirmed entry, however many pile up", () => {
const store = createChatStore({ storage: memStorage() });
for (let i = 0; i < 60; i++) store.getState().markDraftRestoreApplied(`r-${i}`);
const ids = store.getState().appliedDraftRestoreIds;
expect(ids).toHaveLength(60);
expect(ids[0]).toBe("r-0");
expect(ids[59]).toBe("r-59");
// The one exit: the server confirmed the row is gone.
store.getState().forgetDraftRestoreApplied("r-0");
expect(store.getState().appliedDraftRestoreIds).toHaveLength(59);
expect(store.getState().appliedDraftRestoreIds[0]).toBe("r-1");
});
});