Files
multica/apps/mobile/data/realtime/chat-ws-updaters.ts
Bohan Jiang cb87dd106b feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351) (#5195)
* 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>
2026-07-10 16:22:42 +08:00

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);
},
);
}