mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: record created_at/completed_at on reasoning ChatMessageParts (#24789)
Records reasoning start and end times on persisted reasoning `ChatMessagePart`s so reasoning duration can be computed for stored chats. Backend-only: no SSE changes and no frontend rendering ship in this PR. The `created_at` field on `ChatMessagePart` is extended to also be present on `reasoning` parts (it previously appeared only on `tool-call` and `tool-result`), and a new `completed_at` field is added for `reasoning` parts. ### How timestamps are recorded - `StreamPartTypeReasoningStart`: stamp `startedAt = dbtime.Now()` on the active reasoning state. - `StreamPartTypeReasoningEnd`: stamp `completedAt = dbtime.Now()` and append both into parallel `[]time.Time` slices on `stepResult`. - Persistence reads the slices in occurrence order (reasoning has no provider-side ID) and applies them to the matching `ChatMessagePart` via `buildAssistantPartsForPersist`. The first reasoning block's stamps go onto the first reasoning part, and so on. - `flushActiveState` flushes partial reasoning interrupted before `StreamPartTypeReasoningEnd` with `startedAt` from the active state and `completedAt = dbtime.Now()` at the interruption. ### Why two fields, not one? Tool calls and results are point events. The frontend computes their duration by subtracting the call's `created_at` from the result's `created_at`. Reasoning is one assistant part that brackets a span, so we record both endpoints on the part itself. ### Why not stamp in `PartFromContent`? Same rationale as #24101: `PartFromContent` is called during both SSE publishing and persistence. Stamping there would yield incorrect persistence-time timestamps for reasoning blocks that finished much earlier in the step. Instead we capture in the chatloop and apply during persistence. <details><summary>Implementation plan</summary> - `codersdk/chats.go`: extend `CreatedAt`'s `variants` to include `reasoning?`; add `CompletedAt *time.Time` with `variants:"reasoning?"`. - `coderd/x/chatd/chatloop/chatloop.go`: extend `reasoningState` with `startedAt`; extend `stepResult` and `PersistedStep` with parallel `[]time.Time` reasoning slices; stamp on `ReasoningStart`/`ReasoningEnd`; thread the slices through all `PersistStep` call sites including the interrupt-safe path; record partial reasoning in `flushActiveState`. - `coderd/x/chatd/attachments.go`: walk reasoning parts in occurrence order and apply `step.ReasoningStartedAt[i]` to `part.CreatedAt` and `step.ReasoningCompletedAt[i]` to `part.CompletedAt`. ### Tests - `codersdk/chats_test.go` round-trips `created_at` + `completed_at` on reasoning parts and verifies omission when absent and partial interrupted parts. - `coderd/x/chatd/chatprompt/chatprompt_test.go` asserts `PartFromContent(ReasoningContent{})` does NOT stamp timestamps. - `coderd/x/chatd/chatloop/chatloop_test.go` `TestRun_ReasoningTimestamps` drives a stream with two reasoning blocks and verifies parallel slices, monotonicity, ordering, non-zero values, and content-block ordering. `TestRun_InterruptedReasoningFlushesTimestamps` cancels mid-reasoning and verifies `flushActiveState` records a non-zero pair. - `coderd/x/chatd/attachments_test.go` covers `buildAssistantPartsForPersist` for normal interleaved reasoning, partial (zero `completed_at`), and missing slices. </details> > Generated by Coder Agents. Co-authored-by: Coder Agent <agent@coder.com>
This commit is contained in:
co-authored by
Coder Agent
parent
46821525f7
commit
385146000b
+14
-4
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user