mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user