diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b77aec28f6..60a0415e43 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16045,6 +16045,11 @@ const docTemplate = `{ "args_delta": { "type": "string" }, + "completed_at": { + "description": "CompletedAt is the time a reasoning part finished streaming,\nso reasoning duration can be computed as completed_at minus\ncreated_at. For interrupted reasoning, this is the\ninterruption time. Absent when reasoning timestamp data was\nnot recorded (e.g. messages persisted before this feature\nwas added).", + "type": "string", + "format": "date-time" + }, "content": { "description": "The code content from the diff that was commented on.", "type": "string" @@ -16083,7 +16088,7 @@ const docTemplate = `{ "type": "boolean" }, "created_at": { - "description": "CreatedAt records when this part was produced. Present on\ntool-call and tool-result parts so the frontend can compute\ntool execution duration.", + "description": "CreatedAt is the timestamp this part carries. The semantics\ndepend on the part type: for tool-call and tool-result parts\nit is the time the call was emitted or the result was\nproduced (tool duration is the result's created_at minus the\ncall's created_at); for reasoning parts it is the time\nreasoning started streaming.", "type": "string", "format": "date-time" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d6de642bbd..e2f5e16234 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14447,6 +14447,11 @@ "args_delta": { "type": "string" }, + "completed_at": { + "description": "CompletedAt is the time a reasoning part finished streaming,\nso reasoning duration can be computed as completed_at minus\ncreated_at. For interrupted reasoning, this is the\ninterruption time. Absent when reasoning timestamp data was\nnot recorded (e.g. messages persisted before this feature\nwas added).", + "type": "string", + "format": "date-time" + }, "content": { "description": "The code content from the diff that was commented on.", "type": "string" @@ -14485,7 +14490,7 @@ "type": "boolean" }, "created_at": { - "description": "CreatedAt records when this part was produced. Present on\ntool-call and tool-result parts so the frontend can compute\ntool execution duration.", + "description": "CreatedAt is the timestamp this part carries. The semantics\ndepend on the part type: for tool-call and tool-result parts\nit is the time the call was emitted or the result was\nproduced (tool duration is the result's created_at minus the\ncall's created_at); for reasoning parts it is the time\nreasoning started streaming.", "type": "string", "format": "date-time" }, diff --git a/coderd/x/chatd/attachments.go b/coderd/x/chatd/attachments.go index a7a9885fab..ca88dbd2a0 100644 --- a/coderd/x/chatd/attachments.go +++ b/coderd/x/chatd/attachments.go @@ -22,6 +22,11 @@ func buildAssistantPartsForPersist( toolNameToConfigID map[string]uuid.UUID, ) []codersdk.ChatMessagePart { parts := make([]codersdk.ChatMessagePart, 0, len(assistantBlocks)+len(toolResults)) + // reasoningIdx walks reasoning blocks in occurrence order so we + // can apply the matching ReasoningStartedAt/ReasoningCompletedAt + // entry from step onto each reasoning part's CreatedAt and + // CompletedAt. + reasoningIdx := 0 for _, block := range assistantBlocks { part := chatprompt.PartFromContentWithLogger(ctx, logger, block) if part.ToolName != "" { @@ -39,6 +44,19 @@ func buildAssistantPartsForPersist( part.CreatedAt = &ts } } + if part.Type == codersdk.ChatMessagePartTypeReasoning { + if reasoningIdx < len(step.ReasoningStartedAt) { + if ts := step.ReasoningStartedAt[reasoningIdx]; !ts.IsZero() { + part.CreatedAt = &ts + } + } + if reasoningIdx < len(step.ReasoningCompletedAt) { + if ts := step.ReasoningCompletedAt[reasoningIdx]; !ts.IsZero() { + part.CompletedAt = &ts + } + } + reasoningIdx++ + } parts = append(parts, part) } for _, tr := range toolResults { diff --git a/coderd/x/chatd/attachments_test.go b/coderd/x/chatd/attachments_test.go index d3585ba505..c9a6cfb339 100644 --- a/coderd/x/chatd/attachments_test.go +++ b/coderd/x/chatd/attachments_test.go @@ -134,3 +134,114 @@ func TestBuildAssistantPartsForPersist_InvalidAttachmentMetadataSkipsOnlyBrokenR require.Equal(t, "image/png", parts[1].MediaType) require.Equal(t, "good.png", parts[1].Name) } + +func TestBuildAssistantPartsForPersist_AppliesReasoningTimestamps(t *testing.T) { + t.Parallel() + + startedAt1 := time.Date(2026, time.April, 10, 12, 0, 0, 0, time.UTC) + completedAt1 := startedAt1.Add(500 * time.Millisecond) + startedAt2 := completedAt1.Add(time.Second) + completedAt2 := startedAt2.Add(750 * time.Millisecond) + + // Interleave reasoning blocks with a text block to confirm the + // index walks reasoning content in occurrence order without + // being thrown off by non-reasoning entries. + parts := buildAssistantPartsForPersist( + context.Background(), + testutil.Logger(t), + []fantasy.Content{ + fantasy.ReasoningContent{Text: "first thought"}, + fantasy.TextContent{Text: "intermission"}, + fantasy.ReasoningContent{Text: "second thought"}, + }, + nil, + chatloop.PersistedStep{ + ReasoningStartedAt: []time.Time{startedAt1, startedAt2}, + ReasoningCompletedAt: []time.Time{completedAt1, completedAt2}, + }, + nil, + ) + + require.Len(t, parts, 3) + + require.Equal(t, codersdk.ChatMessagePartTypeReasoning, parts[0].Type) + require.Equal(t, "first thought", parts[0].Text) + require.NotNil(t, parts[0].CreatedAt) + require.True(t, parts[0].CreatedAt.Equal(startedAt1), + "first reasoning part must use ReasoningStartedAt[0]") + require.NotNil(t, parts[0].CompletedAt) + require.True(t, parts[0].CompletedAt.Equal(completedAt1), + "first reasoning part must use ReasoningCompletedAt[0]") + + require.Equal(t, codersdk.ChatMessagePartTypeText, parts[1].Type) + require.Nil(t, parts[1].CreatedAt, + "text part must not inherit reasoning timestamps") + require.Nil(t, parts[1].CompletedAt) + + require.Equal(t, codersdk.ChatMessagePartTypeReasoning, parts[2].Type) + require.Equal(t, "second thought", parts[2].Text) + require.NotNil(t, parts[2].CreatedAt) + require.True(t, parts[2].CreatedAt.Equal(startedAt2), + "second reasoning part must use ReasoningStartedAt[1]") + require.NotNil(t, parts[2].CompletedAt) + require.True(t, parts[2].CompletedAt.Equal(completedAt2), + "second reasoning part must use ReasoningCompletedAt[1]") +} + +func TestBuildAssistantPartsForPersist_PartialReasoningTimestamps(t *testing.T) { + t.Parallel() + + startedAt := time.Date(2026, time.April, 10, 12, 0, 0, 0, time.UTC) + + // Tests the persistence helper when the parallel CompletedAt + // slot is zero-valued, ensuring it leaves CompletedAt nil rather + // than setting it to the Go zero time. No production code path + // currently emits a zero CompletedAt alongside a non-zero + // StartedAt (flushActiveState always stamps both with + // dbtime.Now()), so this is a defensive boundary test for the + // `variants:"reasoning?"` contract. + parts := buildAssistantPartsForPersist( + context.Background(), + testutil.Logger(t), + []fantasy.Content{ + fantasy.ReasoningContent{Text: "incomplete thought"}, + }, + nil, + chatloop.PersistedStep{ + ReasoningStartedAt: []time.Time{startedAt}, + ReasoningCompletedAt: []time.Time{{}}, + }, + nil, + ) + + require.Len(t, parts, 1) + require.Equal(t, codersdk.ChatMessagePartTypeReasoning, parts[0].Type) + require.NotNil(t, parts[0].CreatedAt) + require.True(t, parts[0].CreatedAt.Equal(startedAt)) + require.Nil(t, parts[0].CompletedAt, + "zero-valued ReasoningCompletedAt must not produce a stamp") +} + +func TestBuildAssistantPartsForPersist_MissingReasoningTimestamps(t *testing.T) { + t.Parallel() + + // Legacy persisted steps and steps that never observed a + // reasoning block carry empty timestamp slices. The helper must + // leave CreatedAt and CompletedAt nil instead of panicking on + // the out-of-range index. + parts := buildAssistantPartsForPersist( + context.Background(), + testutil.Logger(t), + []fantasy.Content{ + fantasy.ReasoningContent{Text: "no timestamps recorded"}, + }, + nil, + chatloop.PersistedStep{}, + nil, + ) + + require.Len(t, parts, 1) + require.Equal(t, codersdk.ChatMessagePartTypeReasoning, parts[0].Type) + require.Nil(t, parts[0].CreatedAt) + require.Nil(t, parts[0].CompletedAt) +} diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index 25b03d8b6b..c67e2ee6d0 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -97,6 +97,15 @@ type PersistedStep struct { // Applied by the persistence layer to set CreatedAt // on persisted tool-result ChatMessageParts. ToolResultCreatedAt map[string]time.Time + // ReasoningStartedAt and ReasoningCompletedAt are parallel + // slices indexed by the occurrence order of reasoning + // content in Content. The persistence layer walks reasoning + // parts in order and applies these timestamps to the + // corresponding ChatMessageParts so the frontend can render + // reasoning duration. Reasoning parts have no provider-side + // stable ID, so order is the only correlation we have. + ReasoningStartedAt []time.Time + ReasoningCompletedAt []time.Time } // RunOptions configures a single streaming chat loop run. @@ -223,14 +232,16 @@ 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 - toolCallCreatedAt map[string]time.Time - toolResultCreatedAt map[string]time.Time + 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 + reasoningStartedAt []time.Time + reasoningCompletedAt []time.Time } // toResponseMessages converts step content into messages suitable @@ -336,8 +347,9 @@ func (r stepResult) toResponseMessages() []fantasy.Message { // reasoningState accumulates reasoning content and provider // metadata while the stream is in flight. type reasoningState struct { - text string - options fantasy.ProviderMetadata + text string + options fantasy.ProviderMetadata + startedAt time.Time } // Run executes the chat step-stream loop and delegates @@ -575,13 +587,15 @@ 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: chatopenai.ExtractResponseIDIfStored(opts.ProviderOptions, result.providerMetadata), - Runtime: time.Since(stepStart), - ToolCallCreatedAt: result.toolCallCreatedAt, - ToolResultCreatedAt: result.toolResultCreatedAt, + Content: result.content, + Usage: result.usage, + ContextLimit: contextLimit, + ProviderResponseID: chatopenai.ExtractResponseIDIfStored(opts.ProviderOptions, result.providerMetadata), + Runtime: time.Since(stepStart), + ToolCallCreatedAt: result.toolCallCreatedAt, + ToolResultCreatedAt: result.toolResultCreatedAt, + ReasoningStartedAt: result.reasoningStartedAt, + ReasoningCompletedAt: result.reasoningCompletedAt, }); err != nil { if errors.Is(err, ErrInterrupted) { persistInterruptedStep(ctx, opts, &result) @@ -915,8 +929,9 @@ func processStepStream( case fantasy.StreamPartTypeReasoningStart: activeReasoningContent[part.ID] = reasoningState{ - text: part.Delta, - options: part.ProviderMetadata, + text: part.Delta, + options: part.ProviderMetadata, + startedAt: dbtime.Now(), } case fantasy.StreamPartTypeReasoningDelta: @@ -939,6 +954,8 @@ func processStepStream( ProviderMetadata: active.options, } result.content = append(result.content, content) + result.reasoningStartedAt = append(result.reasoningStartedAt, active.startedAt) + result.reasoningCompletedAt = append(result.reasoningCompletedAt, dbtime.Now()) delete(activeReasoningContent, part.ID) } case fantasy.StreamPartTypeToolInputStart: @@ -1376,6 +1393,8 @@ func persistPendingDynamicStep( ProviderResponseID: chatopenai.ExtractResponseIDIfStored(opts.ProviderOptions, result.providerMetadata), Runtime: time.Since(stepStart), PendingDynamicToolCalls: pending, + ReasoningStartedAt: result.reasoningStartedAt, + ReasoningCompletedAt: result.reasoningCompletedAt, }); err != nil { if errors.Is(err, ErrInterrupted) { persistInterruptedStep(ctx, opts, result) @@ -1605,7 +1624,11 @@ func flushActiveState( } } - // Flush partial reasoning content. + // Flush partial reasoning content. The matching + // completedAt is filled in here with the interruption + // time so partial reasoning shows the time spent before + // the interruption. + flushedAt := dbtime.Now() for _, rs := range activeReasoning { if rs.text == "" && !chatsanitize.HasAnthropicSignedReasoningOptions(fantasy.ProviderOptions(rs.options)) { continue @@ -1614,6 +1637,8 @@ func flushActiveState( Text: rs.text, ProviderMetadata: rs.options, }) + result.reasoningStartedAt = append(result.reasoningStartedAt, rs.startedAt) + result.reasoningCompletedAt = append(result.reasoningCompletedAt, flushedAt) } // Flush in-progress tool calls. These haven't received a @@ -1727,9 +1752,11 @@ func persistInterruptedStep( persistCtx := context.WithoutCancel(ctx) if err := opts.PersistStep(persistCtx, PersistedStep{ - Content: content, - ToolCallCreatedAt: toolCallCreatedAt, - ToolResultCreatedAt: toolResultCreatedAt, + Content: content, + ToolCallCreatedAt: toolCallCreatedAt, + ToolResultCreatedAt: toolResultCreatedAt, + ReasoningStartedAt: result.reasoningStartedAt, + ReasoningCompletedAt: result.reasoningCompletedAt, }); err != nil { if opts.OnInterruptedPersistError != nil { opts.OnInterruptedPersistError(err) diff --git a/coderd/x/chatd/chatloop/chatloop_test.go b/coderd/x/chatd/chatloop/chatloop_test.go index be7d766430..445bd2185a 100644 --- a/coderd/x/chatd/chatloop/chatloop_test.go +++ b/coderd/x/chatd/chatloop/chatloop_test.go @@ -4415,3 +4415,170 @@ func TestExecuteSingleTool_MediaBase64Encoding(t *testing.T) { require.Contains(t, textOutput.Text, "world") }) } + +// TestRun_ReasoningTimestamps verifies that StreamPartTypeReasoningStart +// and StreamPartTypeReasoningEnd produce parallel ReasoningStartedAt / +// ReasoningCompletedAt slices on PersistedStep, in the same occurrence +// order as the reasoning content blocks. The frontend computes +// reasoning duration as completed_at - started_at. +func TestRun_ReasoningTimestamps(t *testing.T) { + t.Parallel() + + model := &chattest.FakeModel{ + ProviderName: "fake", + StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) { + return streamFromParts([]fantasy.StreamPart{ + {Type: fantasy.StreamPartTypeReasoningStart, ID: "reason-1"}, + {Type: fantasy.StreamPartTypeReasoningDelta, ID: "reason-1", Delta: "first thought"}, + {Type: fantasy.StreamPartTypeReasoningEnd, ID: "reason-1"}, + {Type: fantasy.StreamPartTypeReasoningStart, ID: "reason-2"}, + {Type: fantasy.StreamPartTypeReasoningDelta, ID: "reason-2", Delta: "second thought"}, + {Type: fantasy.StreamPartTypeReasoningEnd, ID: "reason-2"}, + {Type: fantasy.StreamPartTypeTextStart, ID: "text-1"}, + {Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "answer"}, + {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, "think"), + }, + 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] + + // Both reasoning blocks must produce parallel timestamp entries. + require.Len(t, step.ReasoningStartedAt, 2, + "each StreamPartTypeReasoningEnd must record a started_at") + require.Len(t, step.ReasoningCompletedAt, 2, + "each StreamPartTypeReasoningEnd must record a completed_at") + + // Timestamps must be monotonic per block (completed_at >= started_at), + // and both timestamps must be populated. Asserting only monotonicity + // is not enough: time.Time{} is year 0001, so completed_at.Before(zero) + // is trivially false and a regression that drops the started_at stamp + // would slip past the comparison. + for i := range step.ReasoningStartedAt { + require.False(t, step.ReasoningStartedAt[i].IsZero(), + "started_at[%d] must be non-zero", i) + require.False(t, step.ReasoningCompletedAt[i].IsZero(), + "completed_at[%d] must be non-zero", i) + require.False(t, + step.ReasoningCompletedAt[i].Before(step.ReasoningStartedAt[i]), + "completed_at[%d] must be >= started_at[%d]", i, i) + } + + // Successive blocks must be ordered: reasoning-2 cannot start + // before reasoning-1 completes. + require.False(t, + step.ReasoningStartedAt[1].Before(step.ReasoningCompletedAt[0]), + "reasoning-2 started_at must be >= reasoning-1 completed_at") + + // The reasoning content blocks must appear in the same order + // in step.Content so the persistence layer can correlate by + // occurrence order. + var reasoningOrder []string + for _, c := range step.Content { + if r, ok := fantasy.AsContentType[fantasy.ReasoningContent](c); ok { + reasoningOrder = append(reasoningOrder, r.Text) + } + } + require.Equal(t, []string{"first thought", "second thought"}, reasoningOrder) +} + +func TestRun_InterruptedReasoningFlushesTimestamps(t *testing.T) { + t.Parallel() + + started := make(chan struct{}) + model := &chattest.FakeModel{ + ProviderName: "fake", + StreamFn: func(ctx context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) { + return iter.Seq[fantasy.StreamPart](func(yield func(fantasy.StreamPart) bool) { + parts := []fantasy.StreamPart{ + {Type: fantasy.StreamPartTypeReasoningStart, ID: "reason-1"}, + {Type: fantasy.StreamPartTypeReasoningDelta, ID: "reason-1", Delta: "interrupted thought"}, + } + for _, part := range parts { + if !yield(part) { + return + } + } + + select { + case <-started: + default: + close(started) + } + + <-ctx.Done() + _ = yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeError, + Error: ctx.Err(), + }) + }), nil + }, + } + + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + + go func() { + <-started + cancel(ErrInterrupted) + }() + + var persistedStep PersistedStep + err := Run(ctx, RunOptions{ + Model: model, + Messages: []fantasy.Message{ + textMessage(fantasy.MessageRoleUser, "think"), + }, + MaxSteps: 1, + PersistStep: func(_ context.Context, step PersistedStep) error { + persistedStep = step + return nil + }, + }) + require.ErrorIs(t, err, ErrInterrupted) + + // flushActiveState must have appended exactly one entry to each + // parallel slice, matching the single in-progress reasoning block. + require.Len(t, persistedStep.ReasoningStartedAt, 1, + "interrupted reasoning must flush its started_at") + require.Len(t, persistedStep.ReasoningCompletedAt, 1, + "interrupted reasoning must flush a completed_at stamp") + + // Both timestamps must be populated and the completed stamp + // must be at or after the started stamp. + require.False(t, persistedStep.ReasoningStartedAt[0].IsZero(), + "flushed reasoning started_at must be non-zero") + require.False(t, persistedStep.ReasoningCompletedAt[0].IsZero(), + "flushed reasoning completed_at must be non-zero") + require.False(t, + persistedStep.ReasoningCompletedAt[0].Before(persistedStep.ReasoningStartedAt[0]), + "flushed completed_at must be >= started_at") + + // The flushed reasoning content must appear in step.Content so + // the persistence layer's occurrence-order correlation lines up + // with the timestamp slices. + var reasoningBlocks []fantasy.ReasoningContent + for _, c := range persistedStep.Content { + if r, ok := fantasy.AsContentType[fantasy.ReasoningContent](c); ok { + reasoningBlocks = append(reasoningBlocks, r) + } + } + require.Len(t, reasoningBlocks, 1) + require.Equal(t, "interrupted thought", reasoningBlocks[0].Text) +} diff --git a/coderd/x/chatd/chatprompt/chatprompt_test.go b/coderd/x/chatd/chatprompt/chatprompt_test.go index acccf30f11..24a8c8469d 100644 --- a/coderd/x/chatd/chatprompt/chatprompt_test.go +++ b/coderd/x/chatd/chatprompt/chatprompt_test.go @@ -3008,6 +3008,22 @@ func TestPartFromContent_CreatedAtNotStamped(t *testing.T) { part := chatprompt.PartFromContent(fantasy.TextContent{Text: "hello"}) assert.Nil(t, part.CreatedAt) }) + + t.Run("ReasoningHasNilCreatedAndCompletedAt", func(t *testing.T) { + t.Parallel() + // Same rationale as ToolCall: the chatloop layer records + // reasoning timestamps separately and the persistence + // layer applies them. PartFromContent is called in + // multiple contexts so stamping here would yield + // incorrect durations. + part := chatprompt.PartFromContent(fantasy.ReasoningContent{Text: "thinking"}) + assert.Nil(t, part.CreatedAt) + assert.Nil(t, part.CompletedAt) + + partPtr := chatprompt.PartFromContent(&fantasy.ReasoningContent{Text: "thinking"}) + assert.Nil(t, partPtr.CreatedAt) + assert.Nil(t, partPtr.CompletedAt) + }) } func TestToolResultAntivenom(t *testing.T) { diff --git a/codersdk/chats.go b/codersdk/chats.go index 2baaf87e12..4c8706c42c 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -278,10 +278,20 @@ 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?"` + // CreatedAt is the timestamp this part carries. The semantics + // depend on the part type: for tool-call and tool-result parts + // it is the time the call was emitted or the result was + // produced (tool duration is the result's created_at minus the + // call's created_at); for reasoning parts it is the time + // reasoning started streaming. + CreatedAt *time.Time `json:"created_at,omitempty" format:"date-time" variants:"tool-call?,tool-result?,reasoning?"` + // CompletedAt is the time a reasoning part finished streaming, + // so reasoning duration can be computed as completed_at minus + // created_at. For interrupted reasoning, this is the + // interruption time. Absent when reasoning timestamp data was + // not recorded (e.g. messages persisted before this feature + // was added). + CompletedAt *time.Time `json:"completed_at,omitempty" format:"date-time" variants:"reasoning?"` // 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 880094d65d..f169590050 100644 --- a/codersdk/chats_test.go +++ b/codersdk/chats_test.go @@ -393,6 +393,70 @@ func TestChatMessagePart_CreatedAt_JSON(t *testing.T) { }) } +func TestChatMessagePart_ReasoningTimestamps_JSON(t *testing.T) { + t.Parallel() + + t.Run("RoundTrips", func(t *testing.T) { + t.Parallel() + startedAt := time.Date(2025, 6, 15, 12, 30, 0, 0, time.UTC) + completedAt := startedAt.Add(2 * time.Second) + part := codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeReasoning, + Text: "thinking out loud", + CreatedAt: &startedAt, + CompletedAt: &completedAt, + } + data, err := json.Marshal(part) + require.NoError(t, err) + require.Contains(t, string(data), `"created_at"`) + require.Contains(t, string(data), `"completed_at"`) + + var decoded codersdk.ChatMessagePart + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + require.NotNil(t, decoded.CreatedAt) + require.NotNil(t, decoded.CompletedAt) + require.True(t, startedAt.Equal(*decoded.CreatedAt)) + require.True(t, completedAt.Equal(*decoded.CompletedAt)) + }) + + t.Run("OmittedWhenNil", func(t *testing.T) { + t.Parallel() + part := codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeReasoning, + Text: "thinking out loud", + } + data, err := json.Marshal(part) + require.NoError(t, err) + require.NotContains(t, string(data), `"created_at"`) + require.NotContains(t, string(data), `"completed_at"`) + }) + + t.Run("LegacyCreatedAtWithoutCompletedAt", func(t *testing.T) { + t.Parallel() + // CompletedAt is omitted on messages persisted before this + // feature shipped. Confirm round-trip leaves CompletedAt nil + // while preserving CreatedAt so legacy data does not break + // API consumers. + startedAt := time.Date(2025, 6, 15, 12, 30, 0, 0, time.UTC) + part := codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeReasoning, + Text: "legacy reasoning", + CreatedAt: &startedAt, + } + data, err := json.Marshal(part) + require.NoError(t, err) + require.Contains(t, string(data), `"created_at"`) + require.NotContains(t, string(data), `"completed_at"`) + + var decoded codersdk.ChatMessagePart + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + require.NotNil(t, decoded.CreatedAt) + require.Nil(t, decoded.CompletedAt) + }) +} + func TestModelCostConfig_LegacyNumericJSON(t *testing.T) { t.Parallel() diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 67c6bf6d6f..8c978a28e5 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -88,6 +88,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -172,111 +173,112 @@ Experimental: this endpoint is subject to change. Status Code **200** -| Name | Type | Required | Restrictions | Description | -|-----------------------------------|------------------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `[array item]` | array | false | | | -| `» agent_id` | string(uuid) | false | | | -| `» archived` | boolean | false | | | -| `» build_id` | string(uuid) | false | | | -| `» children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | -| `» client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | | -| `» created_at` | string(date-time) | false | | | -| `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | -| `»» additions` | integer | false | | | -| `»» approved` | boolean | false | | | -| `»» author_avatar_url` | string | false | | | -| `»» author_login` | string | false | | | -| `»» base_branch` | string | false | | | -| `»» changed_files` | integer | false | | | -| `»» changes_requested` | boolean | false | | | -| `»» chat_id` | string(uuid) | false | | | -| `»» commits` | integer | false | | | -| `»» deletions` | integer | false | | | -| `»» head_branch` | string | false | | | -| `»» pr_number` | integer | false | | | -| `»» pull_request_draft` | boolean | false | | | -| `»» pull_request_state` | string | false | | | -| `»» pull_request_title` | string | false | | | -| `»» refreshed_at` | string(date-time) | false | | | -| `»» reviewer_count` | integer | false | | | -| `»» stale_at` | string(date-time) | false | | | -| `»» url` | string | false | | | -| `» files` | array | false | | | -| `»» created_at` | string(date-time) | false | | | -| `»» id` | string(uuid) | false | | | -| `»» mime_type` | string | false | | | -| `»» name` | string | false | | | -| `»» organization_id` | string(uuid) | false | | | -| `»» owner_id` | string(uuid) | false | | | -| `» has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | -| `» id` | string(uuid) | false | | | -| `» labels` | object | false | | | -| `»» [any property]` | string | false | | | -| `» last_error` | [codersdk.ChatError](schemas.md#codersdkchaterror) | false | | | -| `»» detail` | string | false | | Detail is optional provider-specific context shown alongside the normalized error message when available. | -| `»» kind` | [codersdk.ChatErrorKind](schemas.md#codersdkchaterrorkind) | false | | Kind classifies the error for consistent client rendering. | -| `»» message` | string | false | | Message is the normalized, user-facing error message. | -| `»» provider` | string | false | | Provider identifies the upstream model provider when known. | -| `»» retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | -| `»» status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | -| `» last_injected_context` | array | false | | Last injected context holds the most recently persisted injected context parts (AGENTS.md files and skills). It is updated only when context changes, on first workspace attach or agent change. | -| `»» args` | array | false | | | -| `»» args_delta` | string | false | | | -| `»» content` | string | false | | The code content from the diff that was commented on. | -| `»» context_file_agent_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context file. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» context_file_content` | string | false | | Context file content holds the file content sent to the LLM. Internal only: stripped before API responses to keep payloads small. The backend reads it when building the prompt via partsToMessageParts. | -| `»» context_file_directory` | string | false | | Context file directory is the working directory of the workspace agent. Internal only: same purpose as ContextFileOS. | -| `»» context_file_os` | string | false | | Context file os is the operating system of the workspace agent. Internal only: used during prompt expansion so the LLM knows the OS even on turns where InsertSystem is not called. | -| `»» context_file_path` | string | false | | Context file path is the absolute path of a file loaded into the LLM context (e.g. an AGENTS.md instruction file). | -| `»» context_file_skill_meta_file` | string | false | | Context file skill meta file is the basename of the skill meta file (e.g. "SKILL.md") at the time of persistence. Internal only: restored on subsequent turns so the read_skill tool uses the correct filename even when the agent configured a non-default value. | -| `»» context_file_truncated` | boolean | false | | Context file truncated indicates the file exceeded the 64KiB instruction file limit and was truncated. | -| `»» created_at` | string(date-time) | false | | Created at records when this part was produced. Present on tool-call and tool-result parts so the frontend can compute tool execution duration. | -| `»» data` | array | false | | | -| `»» end_line` | integer | false | | | -| `»» file_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» file_name` | string | false | | | -| `»» is_error` | boolean | false | | | -| `»» is_media` | boolean | false | | | -| `»» mcp_server_config_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | -| `»»» uuid` | string | false | | | -| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | -| `»» media_type` | string | false | | | -| `»» name` | string | false | | | -| `»» provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | -| `»» provider_metadata` | array | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | -| `»» result` | array | false | | | -| `»» result_delta` | string | false | | | -| `»» result_reset` | boolean | false | | | -| `»» signature` | string | false | | | -| `»» skill_description` | string | false | | Skill description is the short description from the skill's SKILL.md frontmatter. | -| `»» skill_dir` | string | false | | Skill dir is the absolute path to the skill directory inside the workspace filesystem. Internal only: used by read_skill/read_skill_file tools to locate skill files. | -| `»» skill_name` | string | false | | Skill name is the kebab-case name of a discovered skill from the workspace's .agents/skills/ directory. | -| `»» source_id` | string | false | | | -| `»» start_line` | integer | false | | | -| `»» text` | string | false | | | -| `»» title` | string | false | | | -| `»» tool_call_id` | string | false | | | -| `»» tool_name` | string | false | | | -| `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | | -| `»» url` | string | false | | | -| `» last_model_config_id` | string(uuid) | false | | | -| `» last_turn_summary` | string | false | | | -| `» mcp_server_ids` | array | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» owner_id` | string(uuid) | false | | | -| `» parent_chat_id` | string(uuid) | false | | | -| `» pin_order` | integer | false | | | -| `» plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | -| `» root_chat_id` | string(uuid) | false | | | -| `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | -| `» title` | string | false | | | -| `» updated_at` | string(date-time) | false | | | -| `» warnings` | array | false | | | -| `» workspace_id` | string(uuid) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------------------|------------------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `[array item]` | array | false | | | +| `» agent_id` | string(uuid) | false | | | +| `» archived` | boolean | false | | | +| `» build_id` | string(uuid) | false | | | +| `» children` | [codersdk.Chat](schemas.md#codersdkchat) | false | | Children holds child (subagent) chats nested under this root chat. Always initialized to an empty slice so the JSON field is present as []. Child chats cannot create their own subagents, so nesting depth is capped at 1 and this slice is always empty for child chats. | +| `» client_type` | [codersdk.ChatClientType](schemas.md#codersdkchatclienttype) | false | | | +| `» created_at` | string(date-time) | false | | | +| `» diff_status` | [codersdk.ChatDiffStatus](schemas.md#codersdkchatdiffstatus) | false | | | +| `»» additions` | integer | false | | | +| `»» approved` | boolean | false | | | +| `»» author_avatar_url` | string | false | | | +| `»» author_login` | string | false | | | +| `»» base_branch` | string | false | | | +| `»» changed_files` | integer | false | | | +| `»» changes_requested` | boolean | false | | | +| `»» chat_id` | string(uuid) | false | | | +| `»» commits` | integer | false | | | +| `»» deletions` | integer | false | | | +| `»» head_branch` | string | false | | | +| `»» pr_number` | integer | false | | | +| `»» pull_request_draft` | boolean | false | | | +| `»» pull_request_state` | string | false | | | +| `»» pull_request_title` | string | false | | | +| `»» refreshed_at` | string(date-time) | false | | | +| `»» reviewer_count` | integer | false | | | +| `»» stale_at` | string(date-time) | false | | | +| `»» url` | string | false | | | +| `» files` | array | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» id` | string(uuid) | false | | | +| `»» mime_type` | string | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string(uuid) | false | | | +| `»» owner_id` | string(uuid) | false | | | +| `» has_unread` | boolean | false | | Has unread is true when assistant messages exist beyond the owner's read cursor, which updates on stream connect and disconnect. | +| `» id` | string(uuid) | false | | | +| `» labels` | object | false | | | +| `»» [any property]` | string | false | | | +| `» last_error` | [codersdk.ChatError](schemas.md#codersdkchaterror) | false | | | +| `»» detail` | string | false | | Detail is optional provider-specific context shown alongside the normalized error message when available. | +| `»» kind` | [codersdk.ChatErrorKind](schemas.md#codersdkchaterrorkind) | false | | Kind classifies the error for consistent client rendering. | +| `»» message` | string | false | | Message is the normalized, user-facing error message. | +| `»» provider` | string | false | | Provider identifies the upstream model provider when known. | +| `»» retryable` | boolean | false | | Retryable reports whether the underlying error is transient. | +| `»» status_code` | integer | false | | Status code is the best-effort upstream HTTP status code. | +| `» last_injected_context` | array | false | | Last injected context holds the most recently persisted injected context parts (AGENTS.md files and skills). It is updated only when context changes, on first workspace attach or agent change. | +| `»» args` | array | false | | | +| `»» args_delta` | string | false | | | +| `»» completed_at` | string(date-time) | false | | Completed at is the time a reasoning part finished streaming, so reasoning duration can be computed as completed_at minus created_at. For interrupted reasoning, this is the interruption time. Absent when reasoning timestamp data was not recorded (e.g. messages persisted before this feature was added). | +| `»» content` | string | false | | The code content from the diff that was commented on. | +| `»» context_file_agent_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context file. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | +| `»»» uuid` | string | false | | | +| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | +| `»» context_file_content` | string | false | | Context file content holds the file content sent to the LLM. Internal only: stripped before API responses to keep payloads small. The backend reads it when building the prompt via partsToMessageParts. | +| `»» context_file_directory` | string | false | | Context file directory is the working directory of the workspace agent. Internal only: same purpose as ContextFileOS. | +| `»» context_file_os` | string | false | | Context file os is the operating system of the workspace agent. Internal only: used during prompt expansion so the LLM knows the OS even on turns where InsertSystem is not called. | +| `»» context_file_path` | string | false | | Context file path is the absolute path of a file loaded into the LLM context (e.g. an AGENTS.md instruction file). | +| `»» context_file_skill_meta_file` | string | false | | Context file skill meta file is the basename of the skill meta file (e.g. "SKILL.md") at the time of persistence. Internal only: restored on subsequent turns so the read_skill tool uses the correct filename even when the agent configured a non-default value. | +| `»» context_file_truncated` | boolean | false | | Context file truncated indicates the file exceeded the 64KiB instruction file limit and was truncated. | +| `»» created_at` | string(date-time) | false | | Created at is the timestamp this part carries. The semantics depend on the part type: for tool-call and tool-result parts it is the time the call was emitted or the result was produced (tool duration is the result's created_at minus the call's created_at); for reasoning parts it is the time reasoning started streaming. | +| `»» data` | array | false | | | +| `»» end_line` | integer | false | | | +| `»» file_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | +| `»»» uuid` | string | false | | | +| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | +| `»» file_name` | string | false | | | +| `»» is_error` | boolean | false | | | +| `»» is_media` | boolean | false | | | +| `»» mcp_server_config_id` | [uuid.NullUUID](schemas.md#uuidnulluuid) | false | | | +| `»»» uuid` | string | false | | | +| `»»» valid` | boolean | false | | Valid is true if UUID is not NULL | +| `»» media_type` | string | false | | | +| `»» name` | string | false | | | +| `»» provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | +| `»» provider_metadata` | array | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | +| `»» result` | array | false | | | +| `»» result_delta` | string | false | | | +| `»» result_reset` | boolean | false | | | +| `»» signature` | string | false | | | +| `»» skill_description` | string | false | | Skill description is the short description from the skill's SKILL.md frontmatter. | +| `»» skill_dir` | string | false | | Skill dir is the absolute path to the skill directory inside the workspace filesystem. Internal only: used by read_skill/read_skill_file tools to locate skill files. | +| `»» skill_name` | string | false | | Skill name is the kebab-case name of a discovered skill from the workspace's .agents/skills/ directory. | +| `»» source_id` | string | false | | | +| `»» start_line` | integer | false | | | +| `»» text` | string | false | | | +| `»» title` | string | false | | | +| `»» tool_call_id` | string | false | | | +| `»» tool_name` | string | false | | | +| `»» type` | [codersdk.ChatMessagePartType](schemas.md#codersdkchatmessageparttype) | false | | | +| `»» url` | string | false | | | +| `» last_model_config_id` | string(uuid) | false | | | +| `» last_turn_summary` | string | false | | | +| `» mcp_server_ids` | array | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» owner_id` | string(uuid) | false | | | +| `» parent_chat_id` | string(uuid) | false | | | +| `» pin_order` | integer | false | | | +| `» plan_mode` | [codersdk.ChatPlanMode](schemas.md#codersdkchatplanmode) | false | | | +| `» root_chat_id` | string(uuid) | false | | | +| `» status` | [codersdk.ChatStatus](schemas.md#codersdkchatstatus) | false | | | +| `» title` | string | false | | | +| `» updated_at` | string(date-time) | false | | | +| `» warnings` | array | false | | | +| `» workspace_id` | string(uuid) | false | | | #### Enumerated Values @@ -420,6 +422,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -545,6 +548,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -821,6 +825,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -1000,6 +1005,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -1125,6 +1131,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -1385,6 +1392,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -1510,6 +1518,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -1631,6 +1640,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -1708,6 +1718,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -1837,6 +1848,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -1913,6 +1925,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -2040,6 +2053,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -2225,6 +2239,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -2298,6 +2313,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -2360,6 +2376,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -2609,6 +2626,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -2734,6 +2752,7 @@ Experimental: this endpoint is subject to change. 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 4b53900dc9..87392a67ca 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2182,6 +2182,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -2307,6 +2308,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -2644,6 +2646,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -2734,6 +2737,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -2789,45 +2793,46 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------------------|--------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `args` | array of integer | false | | | -| `args_delta` | string | false | | | -| `content` | string | false | | The code content from the diff that was commented on. | -| `context_file_agent_id` | [uuid.NullUUID](#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context file. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | -| `context_file_content` | string | false | | Context file content holds the file content sent to the LLM. Internal only: stripped before API responses to keep payloads small. The backend reads it when building the prompt via partsToMessageParts. | -| `context_file_directory` | string | false | | Context file directory is the working directory of the workspace agent. Internal only: same purpose as ContextFileOS. | -| `context_file_os` | string | false | | Context file os is the operating system of the workspace agent. Internal only: used during prompt expansion so the LLM knows the OS even on turns where InsertSystem is not called. | -| `context_file_path` | string | false | | Context file path is the absolute path of a file loaded into the LLM context (e.g. an AGENTS.md instruction file). | -| `context_file_skill_meta_file` | string | false | | Context file skill meta file is the basename of the skill meta file (e.g. "SKILL.md") at the time of persistence. Internal only: restored on subsequent turns so the read_skill tool uses the correct filename even when the agent configured a non-default value. | -| `context_file_truncated` | boolean | false | | Context file truncated indicates the file exceeded the 64KiB instruction file limit and was truncated. | -| `created_at` | string | false | | Created at records when this part was produced. Present on tool-call and tool-result parts so the frontend can compute tool execution duration. | -| `data` | array of integer | false | | | -| `end_line` | integer | false | | | -| `file_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | -| `file_name` | string | false | | | -| `is_error` | boolean | false | | | -| `is_media` | boolean | false | | | -| `mcp_server_config_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | -| `media_type` | string | false | | | -| `name` | string | false | | | -| `provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | -| `provider_metadata` | array of integer | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | -| `result` | array of integer | false | | | -| `result_delta` | string | false | | | -| `result_reset` | boolean | false | | | -| `signature` | string | false | | | -| `skill_description` | string | false | | Skill description is the short description from the skill's SKILL.md frontmatter. | -| `skill_dir` | string | false | | Skill dir is the absolute path to the skill directory inside the workspace filesystem. Internal only: used by read_skill/read_skill_file tools to locate skill files. | -| `skill_name` | string | false | | Skill name is the kebab-case name of a discovered skill from the workspace's .agents/skills/ directory. | -| `source_id` | string | false | | | -| `start_line` | integer | false | | | -| `text` | string | false | | | -| `title` | string | false | | | -| `tool_call_id` | string | false | | | -| `tool_name` | string | false | | | -| `type` | [codersdk.ChatMessagePartType](#codersdkchatmessageparttype) | false | | | -| `url` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------------------|--------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `args` | array of integer | false | | | +| `args_delta` | string | false | | | +| `completed_at` | string | false | | Completed at is the time a reasoning part finished streaming, so reasoning duration can be computed as completed_at minus created_at. For interrupted reasoning, this is the interruption time. Absent when reasoning timestamp data was not recorded (e.g. messages persisted before this feature was added). | +| `content` | string | false | | The code content from the diff that was commented on. | +| `context_file_agent_id` | [uuid.NullUUID](#uuidnulluuid) | false | | Context file agent ID is the workspace agent that provided this context file. Used to detect when the agent changes (e.g. workspace rebuilt) so instruction files can be re-persisted with fresh content. | +| `context_file_content` | string | false | | Context file content holds the file content sent to the LLM. Internal only: stripped before API responses to keep payloads small. The backend reads it when building the prompt via partsToMessageParts. | +| `context_file_directory` | string | false | | Context file directory is the working directory of the workspace agent. Internal only: same purpose as ContextFileOS. | +| `context_file_os` | string | false | | Context file os is the operating system of the workspace agent. Internal only: used during prompt expansion so the LLM knows the OS even on turns where InsertSystem is not called. | +| `context_file_path` | string | false | | Context file path is the absolute path of a file loaded into the LLM context (e.g. an AGENTS.md instruction file). | +| `context_file_skill_meta_file` | string | false | | Context file skill meta file is the basename of the skill meta file (e.g. "SKILL.md") at the time of persistence. Internal only: restored on subsequent turns so the read_skill tool uses the correct filename even when the agent configured a non-default value. | +| `context_file_truncated` | boolean | false | | Context file truncated indicates the file exceeded the 64KiB instruction file limit and was truncated. | +| `created_at` | string | false | | Created at is the timestamp this part carries. The semantics depend on the part type: for tool-call and tool-result parts it is the time the call was emitted or the result was produced (tool duration is the result's created_at minus the call's created_at); for reasoning parts it is the time reasoning started streaming. | +| `data` | array of integer | false | | | +| `end_line` | integer | false | | | +| `file_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | +| `file_name` | string | false | | | +| `is_error` | boolean | false | | | +| `is_media` | boolean | false | | | +| `mcp_server_config_id` | [uuid.NullUUID](#uuidnulluuid) | false | | | +| `media_type` | string | false | | | +| `name` | string | false | | | +| `provider_executed` | boolean | false | | Provider executed indicates the tool call was executed by the provider (e.g. Anthropic computer use). | +| `provider_metadata` | array of integer | false | | Provider metadata holds provider-specific response metadata (e.g. Anthropic cache control hints) as raw JSON. Internal only: stripped by db2sdk before API responses. | +| `result` | array of integer | false | | | +| `result_delta` | string | false | | | +| `result_reset` | boolean | false | | | +| `signature` | string | false | | | +| `skill_description` | string | false | | Skill description is the short description from the skill's SKILL.md frontmatter. | +| `skill_dir` | string | false | | Skill dir is the absolute path to the skill directory inside the workspace filesystem. Internal only: used by read_skill/read_skill_file tools to locate skill files. | +| `skill_name` | string | false | | Skill name is the kebab-case name of a discovered skill from the workspace's .agents/skills/ directory. | +| `source_id` | string | false | | | +| `start_line` | integer | false | | | +| `text` | string | false | | | +| `title` | string | false | | | +| `tool_call_id` | string | false | | | +| `tool_name` | string | false | | | +| `type` | [codersdk.ChatMessagePartType](#codersdkchatmessageparttype) | false | | | +| `url` | string | false | | | ## codersdk.ChatMessagePartType @@ -2897,6 +2902,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -2974,6 +2980,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -3191,6 +3198,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -3337,6 +3345,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -3410,6 +3419,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -3472,6 +3482,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -3582,6 +3593,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -3766,6 +3778,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -4179,6 +4192,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -4255,6 +4269,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", @@ -6667,6 +6682,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o 0 ], "args_delta": "string", + "completed_at": "2019-08-24T14:15:22Z", "content": "string", "context_file_agent_id": { "uuid": "string", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 310dc011e0..d756d9b103 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2591,6 +2591,24 @@ export interface ChatQueuedMessage { export interface ChatReasoningPart { readonly type: "reasoning"; readonly text: string; + /** + * CreatedAt is the timestamp this part carries. The semantics + * depend on the part type: for tool-call and tool-result parts + * it is the time the call was emitted or the result was + * produced (tool duration is the result's created_at minus the + * call's created_at); for reasoning parts it is the time + * reasoning started streaming. + */ + readonly created_at?: string; + /** + * CompletedAt is the time a reasoning part finished streaming, + * so reasoning duration can be computed as completed_at minus + * created_at. For interrupted reasoning, this is the + * interruption time. Absent when reasoning timestamp data was + * not recorded (e.g. messages persisted before this feature + * was added). + */ + readonly completed_at?: string; } // From codersdk/chats.go @@ -2793,9 +2811,12 @@ export interface ChatToolCallPart { */ 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. + * CreatedAt is the timestamp this part carries. The semantics + * depend on the part type: for tool-call and tool-result parts + * it is the time the call was emitted or the result was + * produced (tool duration is the result's created_at minus the + * call's created_at); for reasoning parts it is the time + * reasoning started streaming. */ readonly created_at?: string; } @@ -2817,9 +2838,12 @@ export interface ChatToolResultPart { */ 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. + * CreatedAt is the timestamp this part carries. The semantics + * depend on the part type: for tool-call and tool-result parts + * it is the time the call was emitted or the result was + * produced (tool duration is the result's created_at minus the + * call's created_at); for reasoning parts it is the time + * reasoning started streaming. */ readonly created_at?: string; }