From 9f21d0b634d5378c6513e1e079bb505bc9181359 Mon Sep 17 00:00:00 2001 From: Antoine GIRARD Date: Tue, 9 Jun 2026 13:53:30 +0200 Subject: [PATCH] feat(transcript): add timestamps to run transcript entries (MUL-3174) (#3951) Threads the existing task_message.created_at column through the full stack (Go protocol -> REST/WS handlers -> TS types -> transcript dialog) so agent run transcripts show per-entry timestamps, helping users spot stalled runs. Additive, no migration. --- apps/mobile/data/schemas.ts | 1 + packages/core/chat/store.ts | 1 + packages/core/types/events.ts | 1 + .../agent-transcript-dialog.tsx | 15 ++++ .../common/task-transcript/build-timeline.ts | 3 + server/internal/handler/daemon.go | 80 ++++++++++--------- server/pkg/protocol/messages.go | 17 ++-- 7 files changed, 73 insertions(+), 45 deletions(-) diff --git a/apps/mobile/data/schemas.ts b/apps/mobile/data/schemas.ts index 33a0def12..152dc29b7 100644 --- a/apps/mobile/data/schemas.ts +++ b/apps/mobile/data/schemas.ts @@ -306,6 +306,7 @@ export const TaskMessagePayloadSchema: z.ZodType = z.object( content: z.string().optional(), input: z.record(z.string(), z.unknown()).optional(), output: z.string().optional(), + created_at: z.string().optional(), }).loose(); export const TaskMessageListSchema = z.array(TaskMessagePayloadSchema).default([]); diff --git a/packages/core/chat/store.ts b/packages/core/chat/store.ts index d7c706394..bcfcd0296 100644 --- a/packages/core/chat/store.ts +++ b/packages/core/chat/store.ts @@ -64,6 +64,7 @@ export interface ChatTimelineItem { content?: string; input?: Record; output?: string; + created_at?: string; } export interface ChatState { diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index 5d5d0d8af..8fe1f954b 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -223,6 +223,7 @@ export interface TaskMessagePayload { content?: string; input?: Record; output?: string; + created_at?: string; } export interface TaskQueuedPayload { diff --git a/packages/views/common/task-transcript/agent-transcript-dialog.tsx b/packages/views/common/task-transcript/agent-transcript-dialog.tsx index 16cee0a23..f608259fa 100644 --- a/packages/views/common/task-transcript/agent-transcript-dialog.tsx +++ b/packages/views/common/task-transcript/agent-transcript-dialog.tsx @@ -729,6 +729,10 @@ const TranscriptEventRow = ({ const color = getEventColor(item); const label = getEventLabel(item); const summary = getEventSummary(item); + const date = useMemo( + () => (item.created_at ? new Date(item.created_at) : null), + [item.created_at], + ); const hasDetail = (item.type === "tool_use" && item.input && Object.keys(item.input).length > 0) || @@ -785,6 +789,17 @@ const TranscriptEventRow = ({ #{item.seq} + + {/* Timestamp */} + {date && ( + + {date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + + )} {/* Expanded detail */} diff --git a/packages/views/common/task-transcript/build-timeline.ts b/packages/views/common/task-transcript/build-timeline.ts index 39763be06..8dbda7644 100644 --- a/packages/views/common/task-transcript/build-timeline.ts +++ b/packages/views/common/task-transcript/build-timeline.ts @@ -9,6 +9,7 @@ export interface TimelineItem { content?: string; input?: Record; output?: string; + created_at?: string; } function canMergeStreamingText(prev: TimelineItem, next: TimelineItem): boolean { @@ -26,6 +27,7 @@ export function coalesceTimelineItems(items: TimelineItem[]): TimelineItem[] { out[out.length - 1] = { ...prev, content: `${prev.content ?? ""}${item.content ?? ""}`, + created_at: item.created_at ?? prev.created_at, }; continue; } @@ -58,6 +60,7 @@ export function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] { content: msg.content, input: msg.input, output: msg.output, + created_at: msg.created_at, }); } return redactTimelineItems(coalesceTimelineItems(items)); diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index 9084ccbdc..6953cd4e4 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -2080,7 +2080,7 @@ func (h *Handler) ReportTaskMessages(w http.ResponseWriter, r *http.Request) { if msg.Input != nil { inputJSON, _ = json.Marshal(msg.Input) } - h.Queries.CreateTaskMessage(r.Context(), db.CreateTaskMessageParams{ + created, createErr := h.Queries.CreateTaskMessage(r.Context(), db.CreateTaskMessageParams{ TaskID: parseUUID(taskID), Seq: int32(msg.Seq), Type: msg.Type, @@ -2089,17 +2089,27 @@ func (h *Handler) ReportTaskMessages(w http.ResponseWriter, r *http.Request) { Input: inputJSON, Output: pgtype.Text{String: msg.Output, Valid: msg.Output != ""}, }) + if createErr != nil { + slog.Error("failed to create task message", "task_id", taskID, "seq", msg.Seq, "error", createErr) + writeError(w, http.StatusInternalServerError, "failed to persist task message") + return + } if workspaceID != "" { + createdAt := "" + if created.CreatedAt.Valid { + createdAt = created.CreatedAt.Time.UTC().Format(time.RFC3339Nano) + } h.publishTask(protocol.EventTaskMessage, workspaceID, "system", "", taskID, protocol.TaskMessagePayload{ - TaskID: taskID, - IssueID: uuidToString(task.IssueID), - Seq: msg.Seq, - Type: msg.Type, - Tool: msg.Tool, - Content: msg.Content, - Input: msg.Input, - Output: msg.Output, + TaskID: taskID, + IssueID: uuidToString(task.IssueID), + Seq: msg.Seq, + Type: msg.Type, + Tool: msg.Tool, + Content: msg.Content, + Input: msg.Input, + Output: msg.Output, + CreatedAt: createdAt, }) } } @@ -2107,6 +2117,28 @@ func (h *Handler) ReportTaskMessages(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } +func taskMessageToPayload(m db.TaskMessage, taskID, issueID string) protocol.TaskMessagePayload { + var input map[string]any + if m.Input != nil { + json.Unmarshal(m.Input, &input) + } + createdAt := "" + if m.CreatedAt.Valid { + createdAt = m.CreatedAt.Time.UTC().Format(time.RFC3339Nano) + } + return protocol.TaskMessagePayload{ + TaskID: taskID, + IssueID: issueID, + Seq: int(m.Seq), + Type: m.Type, + Tool: m.Tool.String, + Content: m.Content.String, + Input: input, + Output: m.Output.String, + CreatedAt: createdAt, + } +} + // ListTaskMessages returns the persisted messages for a task (for catch-up after reconnect). func (h *Handler) ListTaskMessages(w http.ResponseWriter, r *http.Request) { taskID := chi.URLParam(r, "taskId") @@ -2143,20 +2175,7 @@ func (h *Handler) ListTaskMessages(w http.ResponseWriter, r *http.Request) { resp := make([]protocol.TaskMessagePayload, len(messages)) for i, m := range messages { - var input map[string]any - if m.Input != nil { - json.Unmarshal(m.Input, &input) - } - resp[i] = protocol.TaskMessagePayload{ - TaskID: taskID, - IssueID: issueID, - Seq: int(m.Seq), - Type: m.Type, - Tool: m.Tool.String, - Content: m.Content.String, - Input: input, - Output: m.Output.String, - } + resp[i] = taskMessageToPayload(m, taskID, issueID) } writeJSON(w, http.StatusOK, resp) @@ -2286,20 +2305,7 @@ func (h *Handler) ListTaskMessagesByUser(w http.ResponseWriter, r *http.Request) resp := make([]protocol.TaskMessagePayload, len(messages)) for i, m := range messages { - var input map[string]any - if m.Input != nil { - json.Unmarshal(m.Input, &input) - } - resp[i] = protocol.TaskMessagePayload{ - TaskID: taskID, - IssueID: issueID, - Seq: int(m.Seq), - Type: m.Type, - Tool: m.Tool.String, - Content: m.Content.String, - Input: input, - Output: m.Output.String, - } + resp[i] = taskMessageToPayload(m, taskID, issueID) } writeJSON(w, http.StatusOK, resp) diff --git a/server/pkg/protocol/messages.go b/server/pkg/protocol/messages.go index 500a6141a..0d599d03c 100644 --- a/server/pkg/protocol/messages.go +++ b/server/pkg/protocol/messages.go @@ -40,14 +40,15 @@ type TaskCompletedPayload struct { // 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) + 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) + CreatedAt string `json:"created_at,omitempty"` } // DaemonRegisterPayload is sent from daemon to server on connection.