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.
This commit is contained in:
Antoine GIRARD
2026-06-09 13:53:30 +02:00
committed by GitHub
parent 6d646db577
commit 9f21d0b634
7 changed files with 73 additions and 45 deletions

View File

@@ -306,6 +306,7 @@ export const TaskMessagePayloadSchema: z.ZodType<TaskMessagePayload> = 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([]);

View File

@@ -64,6 +64,7 @@ export interface ChatTimelineItem {
content?: string;
input?: Record<string, unknown>;
output?: string;
created_at?: string;
}
export interface ChatState {

View File

@@ -223,6 +223,7 @@ export interface TaskMessagePayload {
content?: string;
input?: Record<string, unknown>;
output?: string;
created_at?: string;
}
export interface TaskQueuedPayload {

View File

@@ -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 = ({
<span className="shrink-0 text-[10px] text-muted-foreground/50 tabular-nums mt-1">
#{item.seq}
</span>
{/* Timestamp */}
{date && (
<span className="shrink-0 text-[10px] text-muted-foreground/50 tabular-nums mt-1" title={date.toLocaleString()}>
{date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</span>
)}
</div>
{/* Expanded detail */}

View File

@@ -9,6 +9,7 @@ export interface TimelineItem {
content?: string;
input?: Record<string, unknown>;
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));

View File

@@ -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)

View File

@@ -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.