mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
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:
@@ -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([]);
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface ChatTimelineItem {
|
||||
content?: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
|
||||
@@ -223,6 +223,7 @@ export interface TaskMessagePayload {
|
||||
content?: string;
|
||||
input?: Record<string, unknown>;
|
||||
output?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface TaskQueuedPayload {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user