Skip to content

Commit 3205da8

Browse files
authored
fix(copilot): use execution events for tool timing (#1290)
Copilot CLI records authoritative `tool.execution_start` and `tool.execution_complete` timestamps, but Session Analysis currently derives completed call duration from the next persisted message. Resuming a session hours later can therefore attribute the entire idle gap to the final tool call. This change preserves Copilot execution boundaries as canonical tool-result events and uses paired start/completion timestamps for per-call timing in SQLite, PostgreSQL, and DuckDB. When every call in a turn has an authoritative completion boundary, the turn ends at the latest completion instead of the next user message; incomplete and unsupported providers retain the existing fallback behavior. Blocked result categories continue to omit result content while retaining non-content execution metadata needed for timing. The parser data version advances so existing source-backed Copilot sessions are rebuilt with execution events. The Copilot format inventory records the observed event contract, and focused parser, ingestion, and timing regressions cover a completed blocked Read call followed by a 12-hour resumed-session gap. The main review points are the result-event representation in `internal/parser/copilot.go`, blocked-content handling in `internal/sync/engine.go`, the fallback boundary rules in `internal/db/timing.go`, and storage-backend query parity. Fixes #1288 Co-authored-by: Christina7 <Christina7@users.noreply.github.com>
1 parent 222ee0e commit 3205da8

14 files changed

Lines changed: 348 additions & 19 deletions

docs/internal/session-format-sources.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,11 @@ Grok section and remove the explicit registry exception in the coverage test.
221221
cache-write, and reasoning tokens. Copilot accounting is credit-oriented;
222222
Agentsview does not treat credits as USD and does not infer a monetary cost.
223223
- **Agentsview:** `internal/parser/copilot.go` and
224-
`internal/parser/copilot_provider.go`.
224+
`internal/parser/copilot_provider.go`. Reverified 2026-07-28 against local
225+
Copilot CLI 1.0.76-0 transcripts: `tool.execution_start` and
226+
`tool.execution_complete` carry the same `data.toolCallId` and independent
227+
RFC3339 `timestamp` values, providing an exact execution interval even when
228+
the next user message arrives after a long resumed-session idle gap.
225229

226230
## Gemini CLI (`gemini`)
227231

internal/db/db.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,10 @@ const projectIdentityRemoteScrubCompletedKey = "project_identity_remote_scrub_v1
339339
// instead of the generated checkout leaf, and generic hosting fragments defer
340340
// to an enclosing live repository. Existing rows need re-parsing so activity
341341
// is neither fragmented by worktree names nor claimed by nested fixture paths.)
342-
const dataVersion = 75
342+
// (76: Copilot CLI tool execution boundaries. Re-parsing persists
343+
// tool.execution_start and tool.execution_complete timestamps as result events
344+
// so Session Analysis excludes resumed-session idle time from completed calls.)
345+
const dataVersion = 76
343346

344347
const tokenCoverageRepairStatsKey = "token_coverage_repair_v1"
345348

internal/db/db_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,9 +1008,9 @@ func TestMigration_ToolResultEventsTable(t *testing.T) {
10081008
"expected tool_result_events table after reopen")
10091009
}
10101010

1011-
func TestCurrentDataVersionGitWorktreeProjectAttribution(t *testing.T) {
1012-
assert.Equal(t, 75, CurrentDataVersion(),
1013-
"final git worktree project attribution requires a data version bump")
1011+
func TestCurrentDataVersionCopilotToolTiming(t *testing.T) {
1012+
assert.Equal(t, 76, CurrentDataVersion(),
1013+
"Copilot tool execution timing requires a data version bump")
10141014
}
10151015

10161016
func TestInsertMessages_PreservesToolResultEvents(t *testing.T) {

internal/db/timing.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ type CallRow struct {
7474
SubagentSessionID *string
7575
InputJSON string
7676
DurationMs *int64
77+
CompletedAt string
7778
}
7879

7980
// GetSessionTiming computes the per-session timing summary. Returns
@@ -173,6 +174,30 @@ func (db *DB) queryCallRows(
173174
tc.skill_name,
174175
tc.subagent_session_id,
175176
tc.input_json,
177+
(
178+
SELECT tre.timestamp
179+
FROM tool_result_events tre
180+
WHERE tre.session_id = tc.session_id
181+
AND tre.tool_call_message_ordinal = m.ordinal
182+
AND tre.call_index = tc.call_index
183+
AND tre.source = 'tool_execution'
184+
AND tre.status = 'started'
185+
AND NULLIF(tre.timestamp, '') IS NOT NULL
186+
ORDER BY tre.event_index ASC
187+
LIMIT 1
188+
) AS execution_started_at,
189+
(
190+
SELECT tre.timestamp
191+
FROM tool_result_events tre
192+
WHERE tre.session_id = tc.session_id
193+
AND tre.tool_call_message_ordinal = m.ordinal
194+
AND tre.call_index = tc.call_index
195+
AND tre.source = 'tool_execution'
196+
AND tre.status IN ('completed', 'errored')
197+
AND NULLIF(tre.timestamp, '') IS NOT NULL
198+
ORDER BY tre.event_index DESC
199+
LIMIT 1
200+
) AS execution_completed_at,
176201
CASE
177202
WHEN tc.subagent_session_id IS NOT NULL
178203
AND s_sub.started_at IS NOT NULL THEN
@@ -185,6 +210,7 @@ func (db *DB) queryCallRows(
185210
ELSE NULL
186211
END AS subagent_duration_ms
187212
FROM tool_calls tc
213+
JOIN messages m ON m.id = tc.message_id
188214
LEFT JOIN sessions s_sub
189215
ON s_sub.id = tc.subagent_session_id
190216
WHERE tc.session_id = ?
@@ -199,11 +225,12 @@ func (db *DB) queryCallRows(
199225
for rows.Next() {
200226
var r CallRow
201227
var toolUseID, inputJSON sql.NullString
202-
var skill, sub sql.NullString
228+
var skill, sub, executionStarted, executionCompleted sql.NullString
203229
var subDur sql.NullInt64
204230
if err := rows.Scan(
205231
&r.MessageID, &toolUseID, &r.ToolName, &r.Category,
206-
&skill, &sub, &inputJSON, &subDur,
232+
&skill, &sub, &inputJSON, &executionStarted, &executionCompleted,
233+
&subDur,
207234
); err != nil {
208235
return nil, err
209236
}
@@ -224,6 +251,12 @@ func (db *DB) queryCallRows(
224251
if subDur.Valid {
225252
v := subDur.Int64
226253
r.DurationMs = &v
254+
} else if executionStarted.Valid && executionCompleted.Valid {
255+
v := millisBetween(executionStarted.String, executionCompleted.String)
256+
if v >= 0 {
257+
r.DurationMs = &v
258+
r.CompletedAt = executionCompleted.String
259+
}
227260
}
228261
out = append(out, r)
229262
}
@@ -287,6 +320,13 @@ func AssembleTiming(
287320
turnCalls = []CallTiming{}
288321
}
289322

323+
if completedAt, ok := completedCallBoundary(calls, t.MessageID); ok {
324+
v := millisBetween(t.Timestamp, completedAt)
325+
if v >= 0 {
326+
t.DurationMs = &v
327+
}
328+
}
329+
290330
for i := range turnCalls {
291331
turnCalls[i].IsParallel = len(turnCalls) > 1
292332
// Solo non-sub-agent: propagate the turn's duration to the
@@ -347,6 +387,24 @@ func AssembleTiming(
347387
return out
348388
}
349389

390+
func completedCallBoundary(calls []CallRow, messageID int64) (string, bool) {
391+
var boundary string
392+
matched := 0
393+
for _, call := range calls {
394+
if call.MessageID != messageID {
395+
continue
396+
}
397+
matched++
398+
if call.CompletedAt == "" {
399+
return "", false
400+
}
401+
if boundary == "" || millisBetween(boundary, call.CompletedAt) > 0 {
402+
boundary = call.CompletedAt
403+
}
404+
}
405+
return boundary, matched > 0
406+
}
407+
350408
// turnAttribution is the result of attributeTurnGo. RemainderMs is the
351409
// portion of the turn's duration attributed to PrimaryCategory after
352410
// subtracting the union of any sub-agent durations. SubagentDurations

internal/db/timing_test.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ func TestGetSessionTiming_ReadOnlyFixture(t *testing.T) {
2525
timingInsertMessage(t, d, "solo", 2, "user",
2626
"ok", "2026-04-26T10:00:30Z", false)
2727

28+
timingInsertSession(t, d, "completed-call",
29+
"2026-04-26T10:00:00Z", "2026-04-26T22:38:05Z")
30+
timingInsertMessage(t, d, "completed-call", 0, "user",
31+
"run", "2026-04-26T10:00:00Z", false)
32+
timingInsertMessage(t, d, "completed-call", 1, "assistant",
33+
"finishing", "2026-04-26T10:00:01Z", true)
34+
completedCallMsgID := timingMsgID(t, d, "completed-call", 1)
35+
timingInsertToolCall(t, d, "completed-call", completedCallMsgID,
36+
"tu_done", "task_complete", "Other", "")
37+
timingInsertToolResultEvent(t, d, "completed-call", 1, 0,
38+
"tu_done", "started", "2026-04-26T10:00:01.100Z", 0)
39+
timingInsertToolResultEvent(t, d, "completed-call", 1, 0,
40+
"tu_done", "completed", "2026-04-26T10:00:04.825Z", 1)
41+
timingInsertMessage(t, d, "completed-call", 2, "user",
42+
"next request", "2026-04-26T22:38:05Z", false)
43+
2844
timingInsertSession(t, d, "fallback",
2945
"2026-04-26T10:00:00Z", "2026-04-26T10:00:30Z")
3046
timingInsertMessage(t, d, "fallback", 0, "user",
@@ -112,6 +128,23 @@ func TestGetSessionTiming_ReadOnlyFixture(t *testing.T) {
112128
assert.Equal(t, int64(29_000), *got.Turns[0].Calls[0].DurationMs, "call duration")
113129
})
114130

131+
t.Run("completed call excludes idle time before next user message", func(t *testing.T) {
132+
got, err := d.GetSessionTiming(ctx, "completed-call")
133+
require.NoError(t, err, "GetSessionTiming")
134+
require.Len(t, got.Turns, 1)
135+
require.Len(t, got.Turns[0].Calls, 1)
136+
require.NotNil(t, got.Turns[0].DurationMs)
137+
assert.Equal(t, int64(3_825), *got.Turns[0].DurationMs)
138+
require.NotNil(t, got.Turns[0].Calls[0].DurationMs)
139+
assert.Equal(t, int64(3_725), *got.Turns[0].Calls[0].DurationMs)
140+
assert.Equal(t, int64(3_825), got.ToolDurationMs)
141+
require.NotNil(t, got.SlowestCall)
142+
assert.Equal(t, "task_complete", got.SlowestCall.ToolName)
143+
assert.Equal(t, int64(3_725), *got.SlowestCall.DurationMs)
144+
require.Len(t, got.ByCategory, 1)
145+
assert.Equal(t, int64(3_825), got.ByCategory[0].DurationMs)
146+
})
147+
115148
t.Run("last message falls back to session end", func(t *testing.T) {
116149
got, err := d.GetSessionTiming(ctx, "fallback")
117150
require.NoError(t, err, "GetSessionTiming")
@@ -343,8 +376,24 @@ func timingInsertToolCall(
343376
_, err := d.getWriter().ExecContext(context.Background(), `
344377
INSERT INTO tool_calls
345378
(session_id, message_id, tool_use_id, tool_name,
346-
category, input_json, subagent_session_id)
347-
VALUES (?, ?, ?, ?, ?, '{}', ?)
379+
category, input_json, subagent_session_id, call_index)
380+
VALUES (?, ?, ?, ?, ?, '{}', ?, 0)
348381
`, sessionID, messageID, toolUseID, toolName, category, sub)
349382
require.NoError(t, err, "timingInsertToolCall %s/%d", sessionID, messageID)
350383
}
384+
385+
func timingInsertToolResultEvent(
386+
t *testing.T, d *DB, sessionID string, messageOrdinal, callIndex int,
387+
toolUseID, status, timestamp string, eventIndex int,
388+
) {
389+
t.Helper()
390+
_, err := d.getWriter().ExecContext(context.Background(), `
391+
INSERT INTO tool_result_events
392+
(session_id, tool_call_message_ordinal, call_index,
393+
tool_use_id, source, status, content, timestamp, event_index)
394+
VALUES (?, ?, ?, ?, 'tool_execution', ?, '', ?, ?)
395+
`, sessionID, messageOrdinal, callIndex, toolUseID, status, timestamp,
396+
eventIndex)
397+
require.NoError(t, err, "timingInsertToolResultEvent %s/%d", sessionID,
398+
eventIndex)
399+
}

internal/duckdb/messages.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,8 +525,33 @@ func (s *Store) queryCallRows(
525525
SELECT tc.message_id, COALESCE(tc.tool_use_id, ''),
526526
tc.tool_name, tc.category, tc.skill_name,
527527
tc.subagent_session_id, COALESCE(tc.input_json, ''),
528+
(
529+
SELECT tre.timestamp
530+
FROM tool_result_events tre
531+
WHERE tre.session_id = tc.session_id
532+
AND tre.tool_call_message_ordinal = m.ordinal
533+
AND tre.call_index = tc.call_index
534+
AND tre.source = 'tool_execution'
535+
AND tre.status = 'started'
536+
AND tre.timestamp IS NOT NULL
537+
ORDER BY tre.event_index ASC
538+
LIMIT 1
539+
) AS execution_started_at,
540+
(
541+
SELECT tre.timestamp
542+
FROM tool_result_events tre
543+
WHERE tre.session_id = tc.session_id
544+
AND tre.tool_call_message_ordinal = m.ordinal
545+
AND tre.call_index = tc.call_index
546+
AND tre.source = 'tool_execution'
547+
AND tre.status IN ('completed', 'errored')
548+
AND tre.timestamp IS NOT NULL
549+
ORDER BY tre.event_index DESC
550+
LIMIT 1
551+
) AS execution_completed_at,
528552
s_sub.started_at, s_sub.ended_at
529553
FROM tool_calls tc
554+
JOIN messages m ON m.id = tc.message_id
530555
LEFT JOIN sessions s_sub ON s_sub.id = tc.subagent_session_id
531556
WHERE tc.session_id = ?
532557
ORDER BY tc.message_id, tc.call_index`,
@@ -542,10 +567,11 @@ func (s *Store) queryCallRows(
542567
for rows.Next() {
543568
var r db.CallRow
544569
var skill, sub sql.NullString
545-
var startedAt, endedAt any
570+
var executionStarted, executionCompleted, startedAt, endedAt any
546571
if err := rows.Scan(
547572
&r.MessageID, &r.ToolUseID, &r.ToolName, &r.Category,
548-
&skill, &sub, &r.InputJSON, &startedAt, &endedAt,
573+
&skill, &sub, &r.InputJSON, &executionStarted, &executionCompleted,
574+
&startedAt, &endedAt,
549575
); err != nil {
550576
return nil, fmt.Errorf("scanning duckdb timing call: %w", err)
551577
}
@@ -559,6 +585,11 @@ func (s *Store) queryCallRows(
559585
if dur, ok := timingMillis(formatDBTime(startedAt), firstNonEmpty(formatDBTime(endedAt), now)); ok {
560586
r.DurationMs = &dur
561587
}
588+
} else if completedAt := formatDBTime(executionCompleted); completedAt != "" {
589+
if dur, ok := timingMillis(formatDBTime(executionStarted), completedAt); ok {
590+
r.DurationMs = &dur
591+
r.CompletedAt = completedAt
592+
}
562593
}
563594
out = append(out, r)
564595
}

internal/duckdb/store_test.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1623,8 +1623,8 @@ func TestGetSessionTimingPopulatesSharedTimingPayload(t *testing.T) {
16231623
local := newLocalDB(t)
16241624
sessionID := "duck-timing"
16251625
startedAt := "2026-01-20T00:00:00.000Z"
1626-
endedAt := "2026-01-20T00:03:00.000Z"
1627-
sess := syncSession(sessionID, "alpha", "timing first", startedAt, 2)
1626+
endedAt := "2026-01-20T12:38:06.000Z"
1627+
sess := syncSession(sessionID, "alpha", "timing first", startedAt, 3)
16281628
sess.EndedAt = &endedAt
16291629
_, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{{
16301630
Session: sess,
@@ -1636,7 +1636,22 @@ func TestGetSessionTimingPopulatesSharedTimingPayload(t *testing.T) {
16361636
Category: "Read",
16371637
ToolUseID: "tool-timing",
16381638
InputJSON: `{"file_path":"README.md"}`,
1639+
ResultEvents: []db.ToolResultEvent{
1640+
{
1641+
ToolUseID: "tool-timing",
1642+
Source: "tool_execution",
1643+
Status: "started",
1644+
Timestamp: "2026-01-20T00:01:00.100Z",
1645+
},
1646+
{
1647+
ToolUseID: "tool-timing",
1648+
Source: "tool_execution",
1649+
Status: "completed",
1650+
Timestamp: "2026-01-20T00:01:03.825Z",
1651+
},
1652+
},
16391653
}),
1654+
syncMessage(sessionID, 2, "user", "next request", "2026-01-20T12:38:05.000Z"),
16401655
},
16411656
DataVersion: 1,
16421657
ReplaceMessages: true,
@@ -1653,17 +1668,17 @@ func TestGetSessionTimingPopulatesSharedTimingPayload(t *testing.T) {
16531668
require.NoError(t, err)
16541669
require.NotNil(t, timing)
16551670
assert.Equal(t, sessionID, timing.SessionID)
1656-
assert.Equal(t, int64(180000), timing.TotalDurationMs)
1671+
assert.Equal(t, int64(45_486_000), timing.TotalDurationMs)
16571672
assert.Equal(t, 1, timing.TurnCount)
16581673
assert.Equal(t, 1, timing.ToolCallCount)
16591674
assert.False(t, timing.Running)
16601675
require.Len(t, timing.Turns, 1)
16611676
assert.Equal(t, 1, timing.Turns[0].Ordinal)
16621677
require.NotNil(t, timing.Turns[0].DurationMs)
1663-
assert.Equal(t, int64(120000), *timing.Turns[0].DurationMs)
1678+
assert.Equal(t, int64(3_825), *timing.Turns[0].DurationMs)
16641679
require.Len(t, timing.Turns[0].Calls, 1)
16651680
require.NotNil(t, timing.Turns[0].Calls[0].DurationMs)
1666-
assert.Equal(t, int64(120000), *timing.Turns[0].Calls[0].DurationMs)
1681+
assert.Equal(t, int64(3_725), *timing.Turns[0].Calls[0].DurationMs)
16671682
}
16681683

16691684
func TestGetAllMessagesDoesNotTruncateAtDefaultLimit(t *testing.T) {

0 commit comments

Comments
 (0)