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:
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Generated
+12
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user