mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
* fix(chat): collapse chat-done flicker via inline cache write
The chat panel flickered at end-of-turn: live TimelineView unmounted →
short blank + scroll jump → persistent AssistantMessage finally appeared.
Root cause: chat:done's WS handler called setQueryData(pendingTask, {})
synchronously while invalidateQueries(messages) was an async refetch.
The render guard pendingAlreadyPersisted (chat-message-list.tsx:62-68)
expected the persisted message to already be in the messages cache
before pending cleared, but the sync/async ordering broke that guard.
Fix follows TkDodo's "combine setQueryData (active query) + invalidate
(others)" pattern. ChatDonePayload now carries the freshly-persisted
ChatMessage (id, content, elapsed_ms, created_at); the WS handler
writes it into chatKeys.messages BEFORE clearing pending. Same render
tick → AssistantMessage mounts before TimelineView unmounts → no
flicker. invalidate(messages) stays as a fallback for clients that
took the older code path or for content drift (redaction, etc.).
Also slim task:completed's chat branch — chat:done already wrote the
message and cleared pending; task:completed only refreshes the
cross-session pending aggregate that drives the FAB.
Field additions are all `omitempty` / TS `?:` so older clients ignore
them and older servers (no fields populated) fall back to invalidate-
only, preserving prior behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(chat): cover chat done cache handoff
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
161 lines
6.5 KiB
Go
161 lines
6.5 KiB
Go
package protocol
|
|
|
|
import "encoding/json"
|
|
|
|
// Message is the envelope for all WebSocket messages.
|
|
type Message struct {
|
|
Type string `json:"type"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
}
|
|
|
|
// TaskDispatchPayload is sent from server to daemon when a task is assigned.
|
|
type TaskDispatchPayload struct {
|
|
TaskID string `json:"task_id"`
|
|
IssueID string `json:"issue_id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// TaskAvailablePayload is sent from server to daemon as a wakeup hint. The
|
|
// daemon still claims work through the existing HTTP claim endpoint.
|
|
type TaskAvailablePayload struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
TaskID string `json:"task_id,omitempty"`
|
|
}
|
|
|
|
// TaskProgressPayload is sent from daemon to server during task execution.
|
|
type TaskProgressPayload struct {
|
|
TaskID string `json:"task_id"`
|
|
Summary string `json:"summary"`
|
|
Step int `json:"step,omitempty"`
|
|
Total int `json:"total,omitempty"`
|
|
}
|
|
|
|
// TaskCompletedPayload is sent from daemon to server when a task finishes.
|
|
type TaskCompletedPayload struct {
|
|
TaskID string `json:"task_id"`
|
|
PRURL string `json:"pr_url,omitempty"`
|
|
Output string `json:"output,omitempty"`
|
|
}
|
|
|
|
// TaskMessagePayload represents a single agent execution message (tool call, text, etc.)
|
|
type TaskMessagePayload struct {
|
|
TaskID string `json:"task_id"`
|
|
IssueID string `json:"issue_id,omitempty"`
|
|
Seq int `json:"seq"`
|
|
Type string `json:"type"` // "text", "tool_use", "tool_result", "error"
|
|
Tool string `json:"tool,omitempty"` // tool name for tool_use/tool_result
|
|
Content string `json:"content,omitempty"` // text content
|
|
Input map[string]any `json:"input,omitempty"` // tool input (tool_use only)
|
|
Output string `json:"output,omitempty"` // tool output (tool_result only)
|
|
}
|
|
|
|
// DaemonRegisterPayload is sent from daemon to server on connection.
|
|
type DaemonRegisterPayload struct {
|
|
DaemonID string `json:"daemon_id"`
|
|
AgentID string `json:"agent_id"`
|
|
Runtimes []RuntimeInfo `json:"runtimes"`
|
|
}
|
|
|
|
// RuntimeInfo describes an available agent runtime on the daemon's machine.
|
|
type RuntimeInfo struct {
|
|
Type string `json:"type"`
|
|
Version string `json:"version"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// ChatMessagePayload is broadcast when a new chat message is created.
|
|
type ChatMessagePayload struct {
|
|
ChatSessionID string `json:"chat_session_id"`
|
|
MessageID string `json:"message_id"`
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
TaskID string `json:"task_id,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// ChatDonePayload is broadcast when an agent finishes responding to a chat
|
|
// message. Carries the freshly-persisted assistant ChatMessage so the client
|
|
// can write it into the messages cache inline — avoids a refetch round-trip
|
|
// during the live-timeline → AssistantMessage handoff that previously caused
|
|
// a visible flicker (#2123).
|
|
type ChatDonePayload struct {
|
|
ChatSessionID string `json:"chat_session_id"`
|
|
TaskID string `json:"task_id"`
|
|
MessageID string `json:"message_id,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
ElapsedMs int64 `json:"elapsed_ms,omitempty"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
}
|
|
|
|
// ChatSessionReadPayload is broadcast when the creator marks a session as read.
|
|
// Fires to other devices so their unread counts stay in sync.
|
|
type ChatSessionReadPayload struct {
|
|
ChatSessionID string `json:"chat_session_id"`
|
|
}
|
|
|
|
// ChatSessionDeletedPayload is broadcast when a chat session is hard-deleted
|
|
// so other tabs/devices drop it from their session lists and reset the active
|
|
// pointer if it referenced the deleted session.
|
|
type ChatSessionDeletedPayload struct {
|
|
ChatSessionID string `json:"chat_session_id"`
|
|
}
|
|
|
|
// DaemonHeartbeatRequestPayload is sent from daemon to server over WebSocket
|
|
// to update last_seen_at and pull pending actions for a single runtime.
|
|
// Mirrors the body of POST /api/daemon/heartbeat so both transports share
|
|
// identical semantics.
|
|
type DaemonHeartbeatRequestPayload struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
}
|
|
|
|
// DaemonHeartbeatAckPayload is the server's reply to DaemonHeartbeatRequestPayload.
|
|
// JSON shape mirrors the HTTP heartbeat response so daemon code can decode either.
|
|
//
|
|
// RuntimeGone is the WebSocket replacement for the HTTP 404 "runtime not found"
|
|
// response. When the server discovers the runtime row was deleted (UI delete,
|
|
// 7-day offline GC), it sends back an ack with Status=HeartbeatStatusRuntimeGone
|
|
// and RuntimeGone=true rather than tearing down the connection with an error.
|
|
// The daemon reads this signal, prunes the stale runtime from its local state
|
|
// and re-registers; without it the dead UUID would keep heartbeating until the
|
|
// daemon process restarts.
|
|
type DaemonHeartbeatAckPayload struct {
|
|
RuntimeID string `json:"runtime_id"`
|
|
Status string `json:"status"`
|
|
RuntimeGone bool `json:"runtime_gone,omitempty"`
|
|
PendingUpdate *DaemonHeartbeatPendingUpdate `json:"pending_update,omitempty"`
|
|
PendingModelList *DaemonHeartbeatPendingModelList `json:"pending_model_list,omitempty"`
|
|
PendingLocalSkills *DaemonHeartbeatPendingLocalSkills `json:"pending_local_skills,omitempty"`
|
|
PendingLocalSkillImport *DaemonHeartbeatPendingLocalSkillImport `json:"pending_local_skill_import,omitempty"`
|
|
}
|
|
|
|
// HeartbeatStatusRuntimeGone is the ack Status used when the runtime row no
|
|
// longer exists server-side. Companion to DaemonHeartbeatAckPayload.RuntimeGone.
|
|
const HeartbeatStatusRuntimeGone = "runtime_gone"
|
|
|
|
// DaemonHeartbeatPendingUpdate describes a CLI-update action the daemon
|
|
// should run for the runtime.
|
|
type DaemonHeartbeatPendingUpdate struct {
|
|
ID string `json:"id"`
|
|
TargetVersion string `json:"target_version"`
|
|
}
|
|
|
|
// DaemonHeartbeatPendingModelList describes a request for the daemon to
|
|
// enumerate the runtime's supported models.
|
|
type DaemonHeartbeatPendingModelList struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
// DaemonHeartbeatPendingLocalSkills describes a request for the runtime's
|
|
// local-skill inventory.
|
|
type DaemonHeartbeatPendingLocalSkills struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
// DaemonHeartbeatPendingLocalSkillImport describes a request to import a
|
|
// specific runtime local skill.
|
|
type DaemonHeartbeatPendingLocalSkillImport struct {
|
|
ID string `json:"id"`
|
|
SkillKey string `json:"skill_key"`
|
|
}
|