Files
multica/packages/core/chat/queries.test.ts
Naiyuan Qing 2857a4c649 fix(transcript): live-update issue/agent transcript dialog from shared cache (#4452)
The transcript dialog opened from a running task's row showed only a
one-shot snapshot taken at open time: TranscriptButton fetched once via
api.listTaskMessages and cached it locally, never subscribing to the
shared ["task-messages", taskId] cache that the WS task:message stream
already seeds. New tool calls / thinking / text never appeared until the
task finished or the page reloaded.

Add a live-cache mode to the shared TranscriptButton: when isLive and the
parent provides no items and the task id is a persisted UUID, render from
the shared task-messages cache so the open dialog grows in real time. On
open (and again on the running→terminal transition) force a backfill via
api.listTaskMessages and merge it into the cache by seq — taskMessagesOptions
is staleTime:Infinity, so a plain subscription never heals a WS reconnect
gap. The cache observer is read-only (enabled:false) so React Query never
blind-replaces the cache; only the WS handler and the seq-merged backfill
write it. The subscription mounts only while the dialog is open, so closed
live rows add no baseline requests; terminal tasks keep the lazy one-shot
fetch.

Covers issue execution-log and agent activity. Autopilot issue-less
run_only live log is out of scope: the backend doesn't broadcast
task:message for tasks with no issue/chat session, so there's nothing to
subscribe to — backend broadcast unchanged.

Extract mergeTaskMessagesBySeq into packages/core/chat/queries.ts and route
both the realtime task:message handler and the new backfill through it, so
there is one seq-merge semantics for that cache instead of two.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 16:35:29 +08:00

62 lines
1.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { TaskMessagePayload } from "../types/events";
import {
isTaskMessageTaskId,
mergeTaskMessagesBySeq,
taskMessagesOptions,
} from "./queries";
const msg = (seq: number): TaskMessagePayload => ({
task_id: "task-1",
issue_id: "issue-1",
seq,
type: "text",
content: `m${seq}`,
});
describe("taskMessagesOptions", () => {
it("fetches task messages for persisted UUID task ids", () => {
const taskId = "4a2e8d1c-7f9b-4e2a-9c1d-123456789abc";
expect(isTaskMessageTaskId(taskId)).toBe(true);
expect(taskMessagesOptions(taskId).enabled).toBe(true);
});
it("does not fetch task messages for optimistic task ids", () => {
const taskId = "optimistic-optimistic-1778739487737";
expect(isTaskMessageTaskId(taskId)).toBe(false);
expect(taskMessagesOptions(taskId).enabled).toBe(false);
});
});
describe("mergeTaskMessagesBySeq", () => {
it("backfills missing seqs and keeps the list seq-ordered", () => {
const existing = [msg(1), msg(3)];
const merged = mergeTaskMessagesBySeq(existing, [msg(2), msg(4)]);
expect(merged.map((m) => m.seq)).toEqual([1, 2, 3, 4]);
});
it("drops duplicate seqs and lets the existing entry win", () => {
const existing = [{ ...msg(1), content: "ws" }];
const merged = mergeTaskMessagesBySeq(existing, [
{ ...msg(1), content: "refetch" },
msg(2),
]);
expect(merged.map((m) => m.seq)).toEqual([1, 2]);
expect(merged.find((m) => m.seq === 1)?.content).toBe("ws");
});
it("preserves the array reference when nothing new arrives", () => {
const existing = [msg(1), msg(2)];
// Empty incoming and fully-duplicate incoming must both no-op so React
// Query observers don't re-render on replayed events.
expect(mergeTaskMessagesBySeq(existing, [])).toBe(existing);
expect(mergeTaskMessagesBySeq(existing, [msg(1), msg(2)])).toBe(existing);
});
});