feat: add CreatedAt to tool-call and tool-result ChatMessageParts (#24101)

Adds an optional `CreatedAt` timestamp to `tool-call` and `tool-result`
`ChatMessagePart` variants so the frontend can compute tool execution
duration (`result.created_at - call.created_at`).

Timestamps are recorded at the correct moments in the chatloop:
- **Tool-call**: when the model stream emits the tool call
- **Tool-result**: when tool execution completes (or is interrupted)

These are passed through `PersistedStep.PartCreatedAt` so the
persistence layer can apply accurate timestamps to stored parts.
SSE-published parts also carry `CreatedAt` for real-time display.

Old persisted messages without `created_at` deserialize to `nil` — fully
backward compatible.

<details><summary>Implementation notes (Coder Agents
generated)</summary>

### Why not stamp in `PartFromContent`?

`PartFromContent` is called both for SSE publishing (correct timing) and
during persistence (wrong timing — both tool-call and tool-result would
get the same "persistence time" timestamp, yielding ~0 duration).
Instead, timestamps are captured in the chatloop at the right moments
and carried through `PersistedStep.PartCreatedAt` as a
`map[string]time.Time` keyed by `"call:<id>"` / `"result:<id>"`.

### Interrupted tool calls

`persistInterruptedStep` also stamps `CreatedAt` on synthetic error
results for cancelled/interrupted tool calls, so partial duration is
available.

### Files changed

| File | Change |
|------|--------|
| `codersdk/chats.go` | Add `CreatedAt *time.Time` field |
| `codersdk/chats_test.go` | JSON round-trip test |
| `coderd/database/dbtime/dbtime.go` | Add `TimePtr` helper |
| `coderd/x/chatd/chatloop/chatloop.go` | Track timestamps, pass through
`PersistedStep` |
| `coderd/x/chatd/chatd.go` | Apply timestamps during persistence |
| `coderd/x/chatd/chatprompt/chatprompt_test.go` | Verify
`PartFromContent` does NOT stamp |
| `site/src/api/typesGenerated.ts` | Auto-generated |

</details>

---------

Co-authored-by: Ethan <39577870+ethanndickson@users.noreply.github.com>
This commit is contained in:
Kyle Carberry
2026-04-08 12:42:03 -04:00
committed by GitHub
co-authored by Ethan
parent c2592c9f12
commit 35c26ce22a
7 changed files with 405 additions and 25 deletions
+4
View File
@@ -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"`
+36
View File
@@ -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()