mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-13 19:43:27 +02:00
* feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351) Direct (web/mobile) chat no longer uses the last-assistant-row as an implicit input cursor. Each direct send now owns an immutable input batch: - agent_task_queue.chat_input_task_id makes a task the owner of the user messages it must consume; the send path creates the task + user message + attachment bindings + session touch in one transaction, and the daemon is notified only after commit. A claim reads exactly that batch, so a message that arrives mid-run belongs to the next task and is never absorbed. - Auto-retry inherits the root input owner and is queued at a bumped priority, created inside FailTask's transaction so no newer chat task can jump ahead. - CompleteTask writes exactly one assistant outcome inside the completion transaction: a normal message, or a visible no_response outcome (with a non-empty English fallback) when the final output is empty. The write failing rolls the completion back and the handler returns 5xx so the daemon retries; the status CAS keeps it idempotent. chat:done carries message_kind. - Web/desktop/mobile render no_response as a localized 'no text reply' state (keeping the tool timeline), suppress Copy, keep it unread, and keep the session-list preview non-blank. - Legacy/channel tasks (chat_input_task_id NULL) keep the trailing-message selector, so a rolling deploy never replays Slack/Lark history. Co-authored-by: multica-agent <github@multica.ai> * fix(chat): scope no_response to direct tasks; don't cancel task on input read error (MUL-4351) Addresses PR review (Niko): - writeChatCompletionOutcome only writes a no_response row for task-owned direct tasks (chat_input_task_id set). Legacy/channel (Slack/Lark) tasks keep the prior behavior: empty output writes no assistant row, so chat:done carries empty content and the channel outbound silently drops it — the no_response fallback body never reaches an external channel. - The daemon claim distinguishes a genuine zero-input batch from a failed input read: on ListChatInputMessages / ListChatMessages error it returns 5xx and preserves the dispatched task for redelivery instead of cancelling a valid task on a transient DB error. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
214 lines
7.0 KiB
TypeScript
214 lines
7.0 KiB
TypeScript
/**
|
|
* Mobile-owned WS cache patchers for the chat domain.
|
|
*
|
|
* Pure functions over QueryClient — no React, no WS plumbing. The
|
|
* `use-chat-sessions-realtime` and `use-chat-session-realtime` hooks
|
|
* translate WS events into calls into this module.
|
|
*
|
|
* Why mobile-owned (and not importing from web's chat ws-updaters):
|
|
* - Web binds its updaters to `chatKeys` from packages/core/chat/queries.ts,
|
|
* a different runtime instance than mobile's data/queries/chat.ts. Keys
|
|
* are compared structurally so it'd *appear* to work, but binding cache
|
|
* mutation to a foreign key factory invites silent drift the moment
|
|
* either side adjusts its key shape.
|
|
* - Mobile has a smaller cache surface (no taskMessages live timeline in
|
|
* v1, no per-user pending-tasks aggregate).
|
|
*
|
|
* Cache shapes (the design contract):
|
|
* - chatKeys.sessions(wsId) → ChatSession[]
|
|
* - chatKeys.messages(sessionId) → ChatMessage[] (flat, ASC oldest→newest)
|
|
* - chatKeys.pendingTask(sessionId)→ ChatPendingTask (empty `{}` = no in-flight)
|
|
*/
|
|
import type { QueryClient } from "@tanstack/react-query";
|
|
import type {
|
|
ChatDonePayload,
|
|
ChatMessage,
|
|
ChatPendingTask,
|
|
ChatSession,
|
|
ChatSessionDeletedPayload,
|
|
TaskMessagePayload,
|
|
TaskQueuedPayload,
|
|
TaskDispatchPayload,
|
|
} from "@multica/core/types";
|
|
import { chatKeys } from "@/data/queries/chat";
|
|
|
|
// =====================================================
|
|
// Sessions list (ChatSession[] keyed by wsId)
|
|
// =====================================================
|
|
|
|
export function patchSessionListAfterRename(
|
|
qc: QueryClient,
|
|
wsId: string | null,
|
|
payload: {
|
|
chat_session_id: string;
|
|
title?: string;
|
|
updated_at?: string;
|
|
},
|
|
) {
|
|
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
|
|
old?.map((s) =>
|
|
s.id === payload.chat_session_id
|
|
? {
|
|
...s,
|
|
title: payload.title ?? s.title,
|
|
updated_at: payload.updated_at ?? s.updated_at,
|
|
}
|
|
: s,
|
|
),
|
|
);
|
|
}
|
|
|
|
export function dropSessionFromList(
|
|
qc: QueryClient,
|
|
wsId: string | null,
|
|
payload: ChatSessionDeletedPayload,
|
|
) {
|
|
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
|
|
old?.filter((s) => s.id !== payload.chat_session_id),
|
|
);
|
|
qc.removeQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
|
qc.removeQueries({
|
|
queryKey: chatKeys.pendingTask(payload.chat_session_id),
|
|
});
|
|
}
|
|
|
|
export function flipSessionUnread(
|
|
qc: QueryClient,
|
|
wsId: string | null,
|
|
sessionId: string,
|
|
hasUnread: boolean,
|
|
) {
|
|
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
|
|
old?.map((s) =>
|
|
s.id === sessionId ? { ...s, has_unread: hasUnread } : s,
|
|
),
|
|
);
|
|
}
|
|
|
|
// =====================================================
|
|
// Messages cache (ChatMessage[] keyed by sessionId)
|
|
// =====================================================
|
|
|
|
/**
|
|
* Apply `chat:done` to the messages cache.
|
|
*
|
|
* When the payload carries the freshly-persisted assistant message inline
|
|
* (message_id + content + created_at), patch the cache directly so the
|
|
* assistant bubble lands in the same render tick that clears pendingTask
|
|
* — no live-timeline → final-bubble flicker.
|
|
*
|
|
* Older servers (pre-#2123 in web's commit history) sent only chat_session_id
|
|
* + task_id. Detect that and fall back to invalidate; we'll refetch the
|
|
* messages list and accept a one-frame window with no bubble.
|
|
*/
|
|
export function applyChatDoneToCache(
|
|
qc: QueryClient,
|
|
payload: ChatDonePayload,
|
|
) {
|
|
if (payload.message_id && payload.content != null && payload.created_at) {
|
|
const assistantMsg: ChatMessage = {
|
|
id: payload.message_id,
|
|
chat_session_id: payload.chat_session_id,
|
|
role: "assistant",
|
|
content: payload.content,
|
|
task_id: payload.task_id,
|
|
created_at: payload.created_at,
|
|
elapsed_ms: payload.elapsed_ms ?? null,
|
|
// Mirror web's applyChatDoneToCache: carry the kind so a no_response turn
|
|
// renders its notice inline; missing → "message" for older servers
|
|
// (MUL-4351).
|
|
message_kind: payload.message_kind ?? "message",
|
|
};
|
|
qc.setQueryData<ChatMessage[]>(
|
|
chatKeys.messages(payload.chat_session_id),
|
|
(old) => {
|
|
if (!old) return [assistantMsg];
|
|
// Echo guard — server may re-emit on reconnect.
|
|
if (old.some((m) => m.id === assistantMsg.id)) return old;
|
|
return [...old, assistantMsg];
|
|
},
|
|
);
|
|
} else {
|
|
qc.invalidateQueries({
|
|
queryKey: chatKeys.messages(payload.chat_session_id),
|
|
});
|
|
}
|
|
// Clear in-flight pointer in the same tick so StatusPill unmounts and
|
|
// the AssistantMessage owns the rendering.
|
|
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
|
}
|
|
|
|
// =====================================================
|
|
// Pending task (ChatPendingTask keyed by sessionId)
|
|
// =====================================================
|
|
|
|
export function seedPendingTaskFromQueued(
|
|
qc: QueryClient,
|
|
payload: TaskQueuedPayload,
|
|
) {
|
|
if (!payload.chat_session_id) return;
|
|
qc.setQueryData<ChatPendingTask>(
|
|
chatKeys.pendingTask(payload.chat_session_id),
|
|
(old) => ({
|
|
...(old ?? {}),
|
|
task_id: payload.task_id,
|
|
status: "queued",
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function promotePendingTaskToRunning(
|
|
qc: QueryClient,
|
|
payload: TaskDispatchPayload,
|
|
) {
|
|
if (!payload.chat_session_id) return;
|
|
qc.setQueryData<ChatPendingTask>(
|
|
chatKeys.pendingTask(payload.chat_session_id),
|
|
(old) => {
|
|
// Only upgrade if it's the task we already know about. A stale
|
|
// dispatch event for a finished task shouldn't reanimate the pill.
|
|
if (!old || old.task_id !== payload.task_id) return old;
|
|
return { ...old, status: "running" };
|
|
},
|
|
);
|
|
}
|
|
|
|
export function clearPendingTask(
|
|
qc: QueryClient,
|
|
sessionId: string,
|
|
) {
|
|
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
|
|
}
|
|
|
|
// =====================================================
|
|
// Task messages (live timeline, keyed by taskId)
|
|
// =====================================================
|
|
|
|
/**
|
|
* Append a `task:message` payload into the per-task timeline cache.
|
|
*
|
|
* - De-dupes on `seq` (server may re-emit on flaky network).
|
|
* - Sorts by `seq` ASC after insert so reordered late-arriving rows still
|
|
* render in execution order.
|
|
* - Creates the cache entry on first event (empty default), so the timeline
|
|
* is visible even before the user opens the assistant bubble that drives
|
|
* the lazy fetch.
|
|
*
|
|
* Mirrors `packages/core/realtime/use-realtime-sync.ts` ~675-689 (web's
|
|
* single global handler). Mobile attaches per-session via
|
|
* `use-chat-session-realtime` instead — see the WS strategy note in
|
|
* `apps/mobile/CLAUDE.md` for why mobile prefers per-record mounts.
|
|
*/
|
|
export function appendTaskMessage(
|
|
qc: QueryClient,
|
|
payload: TaskMessagePayload,
|
|
) {
|
|
qc.setQueryData<TaskMessagePayload[]>(
|
|
chatKeys.taskMessages(payload.task_id),
|
|
(old = []) => {
|
|
if (old.some((m) => m.seq === payload.seq)) return old;
|
|
return [...old, payload].sort((a, b) => a.seq - b.seq);
|
|
},
|
|
);
|
|
}
|