diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index b02ae12ac8..bd9476a6eb 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -4665,6 +4665,21 @@ func (p *Server) runChat( part.MCPServerConfigID = uuid.NullUUID{UUID: configID, Valid: true} } } + // Apply recorded timestamps so persisted + // tool-call parts carry accurate CreatedAt. + if part.Type == codersdk.ChatMessagePartTypeToolCall && part.ToolCallID != "" && step.ToolCallCreatedAt != nil { + if ts, ok := step.ToolCallCreatedAt[part.ToolCallID]; ok { + part.CreatedAt = &ts + } + } + // Provider-executed tool results appear in + // assistantBlocks rather than toolResults, + // so apply their timestamps here as well. + if part.Type == codersdk.ChatMessagePartTypeToolResult && part.ToolCallID != "" && step.ToolResultCreatedAt != nil { + if ts, ok := step.ToolResultCreatedAt[part.ToolCallID]; ok { + part.CreatedAt = &ts + } + } sdkParts = append(sdkParts, part) } finalAssistantText = strings.TrimSpace(contentBlocksToText(sdkParts)) @@ -4683,6 +4698,13 @@ func (p *Server) runChat( trPart.MCPServerConfigID = uuid.NullUUID{UUID: configID, Valid: true} } } + // Apply recorded timestamps so persisted + // tool-result parts carry accurate CreatedAt. + if trPart.ToolCallID != "" && step.ToolResultCreatedAt != nil { + if ts, ok := step.ToolResultCreatedAt[trPart.ToolCallID]; ok { + trPart.CreatedAt = &ts + } + } var marshalErr error toolResultContents[i], marshalErr = chatprompt.MarshalParts([]codersdk.ChatMessagePart{trPart}) if marshalErr != nil { diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index d380cd8d88..3b768cc41e 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "maps" "slices" "strconv" "strings" @@ -18,6 +19,7 @@ import ( "charm.land/fantasy/schema" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatretry" @@ -75,6 +77,16 @@ type PersistedStep struct { // ErrDynamicToolCall so the caller can execute them // externally and resume the loop. PendingDynamicToolCalls []PendingToolCall + // ToolCallCreatedAt maps tool-call IDs to the time + // the model emitted each tool call. Applied by the + // persistence layer to set CreatedAt on persisted + // tool-call ChatMessageParts. + ToolCallCreatedAt map[string]time.Time + // ToolResultCreatedAt maps tool-call IDs to the time + // each tool result was produced (or interrupted). + // Applied by the persistence layer to set CreatedAt + // on persisted tool-result ChatMessageParts. + ToolResultCreatedAt map[string]time.Time } // RunOptions configures a single streaming chat loop run. @@ -149,12 +161,14 @@ type ProviderTool struct { // step. Since we own the stream consumer, all content is tracked // directly here — no shadow draft state needed. type stepResult struct { - content []fantasy.Content - usage fantasy.Usage - providerMetadata fantasy.ProviderMetadata - finishReason fantasy.FinishReason - toolCalls []fantasy.ToolCallContent - shouldContinue bool + content []fantasy.Content + usage fantasy.Usage + providerMetadata fantasy.ProviderMetadata + finishReason fantasy.FinishReason + toolCalls []fantasy.ToolCallContent + shouldContinue bool + toolCallCreatedAt map[string]time.Time + toolResultCreatedAt map[string]time.Time } // toResponseMessages converts step content into messages suitable @@ -421,11 +435,11 @@ func Run(ctx context.Context, opts RunOptions) error { } // Execute only built-in tools. - toolResults = executeTools(ctx, opts.Tools, opts.ProviderTools, builtinCalls, func(tr fantasy.ToolResultContent) { - publishMessagePart( - codersdk.ChatMessageRoleTool, - chatprompt.PartFromContent(tr), - ) + toolResults = executeTools(ctx, opts.Tools, opts.ProviderTools, builtinCalls, func(tr fantasy.ToolResultContent, completedAt time.Time) { + recordToolResultTimestamp(&result, tr.ToolCallID, completedAt) + ssePart := chatprompt.PartFromContent(tr) + ssePart.CreatedAt = &completedAt + publishMessagePart(codersdk.ChatMessageRoleTool, ssePart) }) for _, tr := range toolResults { result.content = append(result.content, tr) @@ -498,11 +512,13 @@ func Run(ctx context.Context, opts RunOptions) error { // check and here, fall back to the interrupt-safe // path so partial content is not lost. if err := opts.PersistStep(ctx, PersistedStep{ - Content: result.content, - Usage: result.usage, - ContextLimit: contextLimit, - ProviderResponseID: extractOpenAIResponseIDIfStored(opts.ProviderOptions, result.providerMetadata), - Runtime: time.Since(stepStart), + Content: result.content, + Usage: result.usage, + ContextLimit: contextLimit, + ProviderResponseID: extractOpenAIResponseIDIfStored(opts.ProviderOptions, result.providerMetadata), + Runtime: time.Since(stepStart), + ToolCallCreatedAt: result.toolCallCreatedAt, + ToolResultCreatedAt: result.toolResultCreatedAt, }); err != nil { if errors.Is(err, ErrInterrupted) { persistInterruptedStep(ctx, opts, &result) @@ -835,9 +851,20 @@ func processStepStream( // Clean up active tool call tracking. delete(activeToolCalls, part.ID) + // Record when the model emitted this tool call + // so the persisted part carries an accurate + // timestamp for duration computation. + now := dbtime.Now() + if result.toolCallCreatedAt == nil { + result.toolCallCreatedAt = make(map[string]time.Time) + } + result.toolCallCreatedAt[part.ID] = now + + ssePart := chatprompt.PartFromContent(tc) + ssePart.CreatedAt = &now publishMessagePart( codersdk.ChatMessageRoleAssistant, - chatprompt.PartFromContent(tc), + ssePart, ) case fantasy.StreamPartTypeSource: @@ -867,9 +894,18 @@ func processStepStream( ProviderMetadata: part.ProviderMetadata, } result.content = append(result.content, tr) + + now := dbtime.Now() + if result.toolResultCreatedAt == nil { + result.toolResultCreatedAt = make(map[string]time.Time) + } + result.toolResultCreatedAt[part.ID] = now + + ssePart := chatprompt.PartFromContent(tr) + ssePart.CreatedAt = &now publishMessagePart( codersdk.ChatMessageRoleTool, - chatprompt.PartFromContent(tr), + ssePart, ) } case fantasy.StreamPartTypeFinish: @@ -938,7 +974,7 @@ func executeTools( allTools []fantasy.AgentTool, providerTools []ProviderTool, toolCalls []fantasy.ToolCallContent, - onResult func(fantasy.ToolResultContent), + onResult func(fantasy.ToolResultContent, time.Time), ) []fantasy.ToolResultContent { if len(toolCalls) == 0 { return nil @@ -971,10 +1007,11 @@ func executeTools( } results := make([]fantasy.ToolResultContent, len(localToolCalls)) + completedAt := make([]time.Time, len(localToolCalls)) var wg sync.WaitGroup wg.Add(len(localToolCalls)) for i, tc := range localToolCalls { - go func(i int, tc fantasy.ToolCallContent) { + go func() { defer wg.Done() defer func() { if r := recover(); r != nil { @@ -986,17 +1023,21 @@ func executeTools( }, } } + // Record when this tool completed (or panicked). + // Captured per-goroutine so parallel tools get + // accurate individual completion times. + completedAt[i] = dbtime.Now() }() results[i] = executeSingleTool(ctx, toolMap, tc) - }(i, tc) + }() } wg.Wait() // Publish results in the original tool-call order so SSE // subscribers see a deterministic event sequence. if onResult != nil { - for _, tr := range results { - onResult(tr) + for i, tr := range results { + onResult(tr, completedAt[i]) } } return results @@ -1132,11 +1173,24 @@ func persistInterruptedStep( } } + // Copy existing timestamps and add result timestamps for + // interrupted tool calls so the frontend can show partial + // duration. + toolCallCreatedAt := maps.Clone(result.toolCallCreatedAt) + if toolCallCreatedAt == nil { + toolCallCreatedAt = make(map[string]time.Time) + } + toolResultCreatedAt := maps.Clone(result.toolResultCreatedAt) + if toolResultCreatedAt == nil { + toolResultCreatedAt = make(map[string]time.Time) + } + // Build combined content: all accumulated content + synthetic // interrupted results for any unanswered tool calls. content := make([]fantasy.Content, 0, len(result.content)) content = append(content, result.content...) + interruptedAt := dbtime.Now() for _, tc := range result.toolCalls { if tc.ToolCallID == "" { continue @@ -1152,12 +1206,20 @@ func persistInterruptedStep( Error: xerrors.New(interruptedToolResultErrorMessage), }, }) + // Only stamp synthetic results; don't clobber + // timestamps from tools that completed before + // the interruption arrived. + if _, exists := toolResultCreatedAt[tc.ToolCallID]; !exists { + toolResultCreatedAt[tc.ToolCallID] = interruptedAt + } answeredToolCalls[tc.ToolCallID] = struct{}{} } persistCtx := context.WithoutCancel(ctx) if err := opts.PersistStep(persistCtx, PersistedStep{ - Content: content, + Content: content, + ToolCallCreatedAt: toolCallCreatedAt, + ToolResultCreatedAt: toolResultCreatedAt, }); err != nil { if opts.OnInterruptedPersistError != nil { opts.OnInterruptedPersistError(err) @@ -1348,6 +1410,16 @@ func isResponsesStoreEnabled(providerOptions fantasy.ProviderOptions) bool { return false } +// recordToolResultTimestamp lazily initializes the +// toolResultCreatedAt map on the stepResult and records +// the completion timestamp for the given tool-call ID. +func recordToolResultTimestamp(result *stepResult, toolCallID string, ts time.Time) { + if result.toolResultCreatedAt == nil { + result.toolResultCreatedAt = make(map[string]time.Time) + } + result.toolResultCreatedAt[toolCallID] = ts +} + func extractContextLimit(metadata fantasy.ProviderMetadata) sql.NullInt64 { if len(metadata) == 0 { return sql.NullInt64{} diff --git a/coderd/x/chatd/chatloop/chatloop_test.go b/coderd/x/chatd/chatloop/chatloop_test.go index 6424810934..ac61774322 100644 --- a/coderd/x/chatd/chatloop/chatloop_test.go +++ b/coderd/x/chatd/chatloop/chatloop_test.go @@ -535,6 +535,7 @@ func TestRun_InterruptedStepPersistsSyntheticToolResult(t *testing.T) { persistedAssistantCtxErr := xerrors.New("unset") var persistedContent []fantasy.Content + var persistedStep PersistedStep err := Run(ctx, RunOptions{ Model: model, @@ -548,6 +549,7 @@ func TestRun_InterruptedStepPersistsSyntheticToolResult(t *testing.T) { PersistStep: func(persistCtx context.Context, step PersistedStep) error { persistedAssistantCtxErr = persistCtx.Err() persistedContent = append([]fantasy.Content(nil), step.Content...) + persistedStep = step return nil }, }) @@ -587,6 +589,14 @@ func TestRun_InterruptedStepPersistsSyntheticToolResult(t *testing.T) { require.True(t, foundText) require.True(t, foundToolCall) require.True(t, foundToolResult) + + // The interrupted tool was flushed mid-stream (never reached + // StreamPartTypeToolCall), so it has no call timestamp. + // But the synthetic error result must have a result timestamp. + require.Contains(t, persistedStep.ToolResultCreatedAt, "interrupt-tool-1", + "interrupted tool result must have a result timestamp") + require.NotContains(t, persistedStep.ToolCallCreatedAt, "interrupt-tool-1", + "interrupted tool should have no call timestamp (never reached StreamPartTypeToolCall)") } type loopTestModel struct { @@ -727,6 +737,7 @@ func TestRun_MultiStepToolExecution(t *testing.T) { } var persistStepCalls int + var persistedSteps []PersistedStep err := Run(context.Background(), RunOptions{ Model: model, Messages: []fantasy.Message{ @@ -736,8 +747,9 @@ func TestRun_MultiStepToolExecution(t *testing.T) { newNoopTool("read_file"), }, MaxSteps: 5, - PersistStep: func(_ context.Context, _ PersistedStep) error { + PersistStep: func(_ context.Context, step PersistedStep) error { persistStepCalls++ + persistedSteps = append(persistedSteps, step) return nil }, }) @@ -778,6 +790,112 @@ func TestRun_MultiStepToolExecution(t *testing.T) { } require.True(t, foundAssistantToolCall, "second call prompt should contain assistant tool call from step 0") require.True(t, foundToolResult, "second call prompt should contain tool result message") + + // The first persisted step (tool-call step) must carry + // accurate timestamps for duration computation. + require.Len(t, persistedSteps, 2) + toolStep := persistedSteps[0] + require.Contains(t, toolStep.ToolCallCreatedAt, "tc-1", + "tool-call step must record when the model emitted the call") + require.Contains(t, toolStep.ToolResultCreatedAt, "tc-1", + "tool-call step must record when the tool result was produced") + require.False(t, toolStep.ToolResultCreatedAt["tc-1"].Before(toolStep.ToolCallCreatedAt["tc-1"]), + "tool-result timestamp must be >= tool-call timestamp") +} + +func TestRun_ParallelToolExecutionTimestamps(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var streamCalls int + + model := &loopTestModel{ + provider: "fake", + streamFn: func(_ context.Context, call fantasy.Call) (fantasy.StreamResponse, error) { + mu.Lock() + step := streamCalls + streamCalls++ + mu.Unlock() + + _ = call + + switch step { + case 0: + // Step 0: produce two tool calls in one stream. + return streamFromParts([]fantasy.StreamPart{ + {Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-1", ToolCallName: "read_file"}, + {Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-1", Delta: `{"path":"a.go"}`}, + {Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-1"}, + { + Type: fantasy.StreamPartTypeToolCall, + ID: "tc-1", + ToolCallName: "read_file", + ToolCallInput: `{"path":"a.go"}`, + }, + {Type: fantasy.StreamPartTypeToolInputStart, ID: "tc-2", ToolCallName: "write_file"}, + {Type: fantasy.StreamPartTypeToolInputDelta, ID: "tc-2", Delta: `{"path":"b.go"}`}, + {Type: fantasy.StreamPartTypeToolInputEnd, ID: "tc-2"}, + { + Type: fantasy.StreamPartTypeToolCall, + ID: "tc-2", + ToolCallName: "write_file", + ToolCallInput: `{"path":"b.go"}`, + }, + {Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonToolCalls}, + }), nil + default: + // Step 1: return plain text. + return streamFromParts([]fantasy.StreamPart{ + {Type: fantasy.StreamPartTypeTextStart, ID: "text-1"}, + {Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "all done"}, + {Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"}, + {Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop}, + }), nil + } + }, + } + + var persistedSteps []PersistedStep + err := Run(context.Background(), RunOptions{ + Model: model, + Messages: []fantasy.Message{ + textMessage(fantasy.MessageRoleUser, "do both"), + }, + Tools: []fantasy.AgentTool{ + newNoopTool("read_file"), + newNoopTool("write_file"), + }, + MaxSteps: 5, + PersistStep: func(_ context.Context, step PersistedStep) error { + persistedSteps = append(persistedSteps, step) + return nil + }, + }) + require.NoError(t, err) + + // Two steps: tool-call step + text step. + require.Equal(t, 2, streamCalls) + require.Len(t, persistedSteps, 2) + + toolStep := persistedSteps[0] + + // Both tool-call IDs must appear in ToolCallCreatedAt. + require.Contains(t, toolStep.ToolCallCreatedAt, "tc-1", + "tool-call step must record when tc-1 was emitted") + require.Contains(t, toolStep.ToolCallCreatedAt, "tc-2", + "tool-call step must record when tc-2 was emitted") + + // Both tool-call IDs must appear in ToolResultCreatedAt. + require.Contains(t, toolStep.ToolResultCreatedAt, "tc-1", + "tool-call step must record when tc-1 result was produced") + require.Contains(t, toolStep.ToolResultCreatedAt, "tc-2", + "tool-call step must record when tc-2 result was produced") + + // Result timestamps must be >= call timestamps for both. + require.False(t, toolStep.ToolResultCreatedAt["tc-1"].Before(toolStep.ToolCallCreatedAt["tc-1"]), + "tc-1 tool-result timestamp must be >= tool-call timestamp") + require.False(t, toolStep.ToolResultCreatedAt["tc-2"].Before(toolStep.ToolCallCreatedAt["tc-2"]), + "tc-2 tool-result timestamp must be >= tool-call timestamp") } func TestRun_PersistStepErrorPropagates(t *testing.T) { @@ -1183,6 +1301,77 @@ func TestRun_InterruptedDuringToolExecutionPersistsStep(t *testing.T) { require.True(t, foundToolResult, "persisted content should include the tool result (error from cancellation)") } +// TestRun_ProviderExecutedToolResultTimestamps verifies that +// provider-executed tool results (e.g. web search) have their +// timestamps recorded in PersistedStep.ToolResultCreatedAt so +// the persistence layer can stamp CreatedAt on the parts. +func TestRun_ProviderExecutedToolResultTimestamps(t *testing.T) { + t.Parallel() + + model := &loopTestModel{ + provider: "fake", + streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) { + // Simulate a provider-executed tool call and result + // (e.g. Anthropic web search) followed by a text + // response — all in a single stream. + return streamFromParts([]fantasy.StreamPart{ + {Type: fantasy.StreamPartTypeToolInputStart, ID: "ws-1", ToolCallName: "web_search", ProviderExecuted: true}, + {Type: fantasy.StreamPartTypeToolInputDelta, ID: "ws-1", Delta: `{"query":"coder"}`, ProviderExecuted: true}, + {Type: fantasy.StreamPartTypeToolInputEnd, ID: "ws-1"}, + { + Type: fantasy.StreamPartTypeToolCall, + ID: "ws-1", + ToolCallName: "web_search", + ToolCallInput: `{"query":"coder"}`, + ProviderExecuted: true, + }, + // Provider-executed tool result — emitted by + // the provider, not our tool runner. + { + Type: fantasy.StreamPartTypeToolResult, + ID: "ws-1", + ToolCallName: "web_search", + ProviderExecuted: true, + }, + {Type: fantasy.StreamPartTypeTextStart, ID: "text-1"}, + {Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "search done"}, + {Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"}, + {Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop}, + }), nil + }, + } + + var persistedSteps []PersistedStep + err := Run(context.Background(), RunOptions{ + Model: model, + Messages: []fantasy.Message{ + textMessage(fantasy.MessageRoleUser, "search for coder"), + }, + MaxSteps: 1, + PersistStep: func(_ context.Context, step PersistedStep) error { + persistedSteps = append(persistedSteps, step) + return nil + }, + }) + require.NoError(t, err) + require.Len(t, persistedSteps, 1) + + step := persistedSteps[0] + + // Provider-executed tool call should have a call timestamp. + require.Contains(t, step.ToolCallCreatedAt, "ws-1", + "provider-executed tool call must record its timestamp") + + // Provider-executed tool result should have a result + // timestamp so the frontend can compute duration. + require.Contains(t, step.ToolResultCreatedAt, "ws-1", + "provider-executed tool result must record its timestamp") + + require.False(t, + step.ToolResultCreatedAt["ws-1"].Before(step.ToolCallCreatedAt["ws-1"]), + "tool-result timestamp must be >= tool-call timestamp") +} + // TestRun_PersistStepInterruptedFallback verifies that when the normal // PersistStep call returns ErrInterrupted (e.g., context canceled in a // race), the step is retried via the interrupt-safe path. diff --git a/coderd/x/chatd/chatprompt/chatprompt_test.go b/coderd/x/chatd/chatprompt/chatprompt_test.go index 766baca98f..4507fc533d 100644 --- a/coderd/x/chatd/chatprompt/chatprompt_test.go +++ b/coderd/x/chatd/chatprompt/chatprompt_test.go @@ -2329,3 +2329,48 @@ func TestMediaToolResultRoundTrip(t *testing.T) { require.True(t, isText, "expected ToolResultOutputContentText") }) } + +func TestPartFromContent_CreatedAtNotStamped(t *testing.T) { + t.Parallel() + + // PartFromContent must NOT stamp CreatedAt itself. + // The chatloop layer records timestamps separately and + // the persistence layer applies them. PartFromContent + // is called in multiple contexts (SSE publishing, + // persistence) so stamping inside it would produce + // inaccurate durations. + + t.Run("ToolCallHasNilCreatedAt", func(t *testing.T) { + t.Parallel() + part := chatprompt.PartFromContent(fantasy.ToolCallContent{ + ToolCallID: "tc-1", + ToolName: "execute", + }) + assert.Nil(t, part.CreatedAt) + }) + + t.Run("ToolCallPointerHasNilCreatedAt", func(t *testing.T) { + t.Parallel() + part := chatprompt.PartFromContent(&fantasy.ToolCallContent{ + ToolCallID: "tc-1", + ToolName: "execute", + }) + assert.Nil(t, part.CreatedAt) + }) + + t.Run("ToolResultHasNilCreatedAt", func(t *testing.T) { + t.Parallel() + part := chatprompt.PartFromContent(fantasy.ToolResultContent{ + ToolCallID: "tc-1", + ToolName: "execute", + Result: fantasy.ToolResultOutputContentText{Text: "{}"}, + }) + assert.Nil(t, part.CreatedAt) + }) + + t.Run("TextHasNilCreatedAt", func(t *testing.T) { + t.Parallel() + part := chatprompt.PartFromContent(fantasy.TextContent{Text: "hello"}) + assert.Nil(t, part.CreatedAt) + }) +} diff --git a/codersdk/chats.go b/codersdk/chats.go index 07f0cce878..11062ed052 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -214,6 +214,10 @@ type ChatMessagePart struct { // ProviderExecuted indicates the tool call was executed by // the provider (e.g. Anthropic computer use). ProviderExecuted bool `json:"provider_executed,omitempty" variants:"tool-call?,tool-result?"` + // CreatedAt records when this part was produced. Present on + // tool-call and tool-result parts so the frontend can compute + // tool execution duration. + CreatedAt *time.Time `json:"created_at,omitempty" format:"date-time" variants:"tool-call?,tool-result?"` // ContextFilePath is the absolute path of a file loaded into // the LLM context (e.g. an AGENTS.md instruction file). ContextFilePath string `json:"context_file_path" variants:"context-file"` diff --git a/codersdk/chats_test.go b/codersdk/chats_test.go index e2afe06c86..6d0fb732b7 100644 --- a/codersdk/chats_test.go +++ b/codersdk/chats_test.go @@ -329,6 +329,42 @@ func TestChatMessagePartVariantTags(t *testing.T) { }) } +func TestChatMessagePart_CreatedAt_JSON(t *testing.T) { + t.Parallel() + + t.Run("RoundTrips", func(t *testing.T) { + t.Parallel() + ts := time.Date(2025, 6, 15, 12, 30, 0, 0, time.UTC) + part := codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: "tc-1", + ToolName: "execute", + CreatedAt: &ts, + } + data, err := json.Marshal(part) + require.NoError(t, err) + require.Contains(t, string(data), `"created_at"`) + + var decoded codersdk.ChatMessagePart + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + require.NotNil(t, decoded.CreatedAt) + require.True(t, ts.Equal(*decoded.CreatedAt)) + }) + + t.Run("OmittedWhenNil", func(t *testing.T) { + t.Parallel() + part := codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeToolCall, + ToolCallID: "tc-1", + ToolName: "execute", + } + data, err := json.Marshal(part) + require.NoError(t, err) + require.NotContains(t, string(data), `"created_at"`) + }) +} + func TestModelCostConfig_LegacyNumericJSON(t *testing.T) { t.Parallel() diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2809c2f19b..c87edd0308 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2129,6 +2129,12 @@ export interface ChatToolCallPart { * the provider (e.g. Anthropic computer use). */ readonly provider_executed?: boolean; + /** + * CreatedAt records when this part was produced. Present on + * tool-call and tool-result parts so the frontend can compute + * tool execution duration. + */ + readonly created_at?: string; } // From codersdk/chats.go @@ -2145,6 +2151,12 @@ export interface ChatToolResultPart { * the provider (e.g. Anthropic computer use). */ readonly provider_executed?: boolean; + /** + * CreatedAt records when this part was produced. Present on + * tool-call and tool-result parts so the frontend can compute + * tool execution duration. + */ + readonly created_at?: string; } // From codersdk/chats.go