Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions desktop/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,17 @@ type wireAsk struct {
}

type wireTool struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Args string `json:"args,omitempty"`
Output string `json:"output,omitempty"`
Err string `json:"err,omitempty"`
ReadOnly bool `json:"readOnly"`
Truncated bool `json:"truncated,omitempty"`
Partial bool `json:"partial,omitempty"`
ParentID string `json:"parentId,omitempty"`
Profile *wireProfile `json:"profile,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name"`
Args string `json:"args,omitempty"`
Output string `json:"output,omitempty"`
Err string `json:"err,omitempty"`
ReadOnly bool `json:"readOnly"`
Truncated bool `json:"truncated,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render the emitted duration in the frontend

When a tool result includes this new durationMs field, the desktop frontend still drops it: WireTool/Item have no durationMs, the tool_result reducer never copies it, and ToolCard only renders the status glyph (I checked desktop/frontend/src and there are no durationMs references). As a result the backend now emits timing data, but desktop users still won't see tool call durations for any completed tool call.

Useful? React with 👍 / 👎.

Partial bool `json:"partial,omitempty"`
ParentID string `json:"parentId,omitempty"`
Profile *wireProfile `json:"profile,omitempty"`
}

type wireProfile struct {
Expand Down Expand Up @@ -157,7 +158,8 @@ func toWire(e event.Event) wireEvent {
ID: e.Tool.ID, Name: e.Tool.Name, Args: e.Tool.Args,
Output: e.Tool.Output, Err: e.Tool.Err,
ReadOnly: e.Tool.ReadOnly, Truncated: e.Tool.Truncated,
Partial: e.Tool.Partial, ParentID: e.Tool.ParentID,
DurationMs: e.Tool.DurationMs, Partial: e.Tool.Partial,
ParentID: e.Tool.ParentID,
}
if e.Tool.Profile != nil {
wt.Profile = &wireProfile{Model: e.Tool.Profile.Model, Effort: e.Tool.Profile.Effort}
Expand Down
4 changes: 2 additions & 2 deletions desktop/wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ func TestToWireToolDispatchProfile(t *testing.T) {
}

func TestToWireToolResult(t *testing.T) {
e := event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "1", Output: "ok", Truncated: true}}
e := event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "1", Output: "ok", Truncated: true, DurationMs: 522}}
w := toWire(e)
if w.Tool == nil || w.Tool.Output != "ok" || !w.Tool.Truncated {
if w.Tool == nil || w.Tool.Output != "ok" || !w.Tool.Truncated || w.Tool.DurationMs != 522 {
t.Errorf("tool result = %+v", w.Tool)
}
}
Expand Down
19 changes: 12 additions & 7 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"

"reasonix/internal/diff"
Expand Down Expand Up @@ -729,8 +730,11 @@ func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) []s

results := make([]string, len(calls))
outcomes := make([]toolOutcome, len(calls))
durations := make([]int64, len(calls))
run := func(i int) {
start := time.Now()
outcomes[i] = a.executeOne(ctx, calls[i])
durations[i] = time.Since(start).Milliseconds()
results[i] = outcomes[i].output
}

Expand All @@ -748,13 +752,14 @@ func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) []s
o := outcomes[i]
t, ok := a.tools.Get(c.Name)
a.sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{
ID: c.ID,
Name: c.Name,
Args: c.Arguments,
Output: o.output,
Err: o.errMsg,
ReadOnly: ok && t.ReadOnly(),
Truncated: o.truncated,
ID: c.ID,
Name: c.Name,
Args: c.Arguments,
Output: o.output,
Err: o.errMsg,
ReadOnly: ok && t.ReadOnly(),
Truncated: o.truncated,
DurationMs: durations[i],
}})
if o.truncated && o.truncMsg != "" {
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: o.truncMsg})
Expand Down
8 changes: 5 additions & 3 deletions internal/control/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -624,26 +624,28 @@ func (c *Controller) RunShell(command string) {
}})
cmd.Stdout = w
cmd.Stderr = w
start := time.Now()
err := cmd.Run()
durationMs := time.Since(start).Milliseconds()
out := buf.String()

if ctx.Err() == context.DeadlineExceeded {
c.sink.Emit(event.Event{
Kind: event.ToolResult,
Tool: event.Tool{ID: id, Name: "bash", Output: out, Err: fmt.Sprintf(i18n.M.ShellExecTimeoutFmt, shellTimeout)},
Tool: event.Tool{ID: id, Name: "bash", Output: out, Err: fmt.Sprintf(i18n.M.ShellExecTimeoutFmt, shellTimeout), DurationMs: durationMs},
})
return nil
}
if err != nil {
c.sink.Emit(event.Event{
Kind: event.ToolResult,
Tool: event.Tool{ID: id, Name: "bash", Output: out, Err: fmt.Sprintf(i18n.M.ShellExecFailedFmt, err)},
Tool: event.Tool{ID: id, Name: "bash", Output: out, Err: fmt.Sprintf(i18n.M.ShellExecFailedFmt, err), DurationMs: durationMs},
})
return nil
}
c.sink.Emit(event.Event{
Kind: event.ToolResult,
Tool: event.Tool{ID: id, Name: "bash", Output: out},
Tool: event.Tool{ID: id, Name: "bash", Output: out, DurationMs: durationMs},
})
return nil
})
Expand Down
15 changes: 8 additions & 7 deletions internal/event/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,14 @@ type Profile struct {
// only ID/Name/Args/ReadOnly are set; on result Output/Err/Truncated are filled
// in. Args is the raw JSON arguments — a sink compacts it for display.
type Tool struct {
ID string
Name string
Args string
Output string // ToolResult: the result text fed to the model
Err string // ToolResult: non-empty when the call failed or was blocked
ReadOnly bool
Truncated bool // ToolResult: Output was head+tailed before display/model
ID string
Name string
Args string
Output string // ToolResult: the result text fed to the model
Err string // ToolResult: non-empty when the call failed or was blocked
ReadOnly bool
Truncated bool // ToolResult: Output was head+tailed before display/model
DurationMs int64 // ToolResult: wall-clock execution time in milliseconds
// Partial marks an early ToolDispatch emitted when a call begins (ID/Name set,
// Args still streaming) so a frontend can show the card immediately; a second,
// full ToolDispatch (Partial false, Args set) follows when the call completes.
Expand Down
24 changes: 13 additions & 11 deletions internal/serve/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,17 @@ type wireProfile struct {
}

type wireTool struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Args string `json:"args,omitempty"`
Output string `json:"output,omitempty"`
Err string `json:"err,omitempty"`
ReadOnly bool `json:"readOnly"`
Truncated bool `json:"truncated,omitempty"`
Partial bool `json:"partial,omitempty"`
ParentID string `json:"parentId,omitempty"`
Profile *wireProfile `json:"profile,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name"`
Args string `json:"args,omitempty"`
Output string `json:"output,omitempty"`
Err string `json:"err,omitempty"`
ReadOnly bool `json:"readOnly"`
Truncated bool `json:"truncated,omitempty"`
DurationMs int64 `json:"durationMs,omitempty"`
Partial bool `json:"partial,omitempty"`
ParentID string `json:"parentId,omitempty"`
Profile *wireProfile `json:"profile,omitempty"`
}

type wireUsage struct {
Expand Down Expand Up @@ -150,7 +151,8 @@ func toWire(e event.Event) wireEvent {
ID: e.Tool.ID, Name: e.Tool.Name, Args: e.Tool.Args,
Output: e.Tool.Output, Err: e.Tool.Err,
ReadOnly: e.Tool.ReadOnly, Truncated: e.Tool.Truncated,
Partial: e.Tool.Partial, ParentID: e.Tool.ParentID,
DurationMs: e.Tool.DurationMs, Partial: e.Tool.Partial,
ParentID: e.Tool.ParentID,
}
if e.Tool.Profile != nil {
wt.Profile = &wireProfile{Model: e.Tool.Profile.Model, Effort: e.Tool.Profile.Effort}
Expand Down
7 changes: 7 additions & 0 deletions internal/serve/wire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ func TestToWire(t *testing.T) {
}
})

t.Run("tool result duration", func(t *testing.T) {
w := toWire(event.Event{Kind: event.ToolResult, Tool: event.Tool{Name: "web_fetch", Output: "ok", DurationMs: 522}})
if w.Tool == nil || w.Tool.Output != "ok" || w.Tool.DurationMs != 522 {
t.Errorf("tool result duration = %+v", w.Tool)
}
})

t.Run("usage with cost", func(t *testing.T) {
w := toWire(event.Event{
Kind: event.Usage,
Expand Down
Loading