Skip to content

Commit ff64b3c

Browse files
Ubuntuclaude
andcommitted
fix(claude-code,m4): turn.result from JSONL turn_duration frame + synth token pill (v1.0.668-alpha)
ADR-027 W11 fix-up wedge #12. Two M4 polish gaps from v1.0.667 dev-box smoke: context chip works now, but cancel-button stuck after MCP-tool turns + token-flow pill blank. - Cancel-button race: hookStop posted turn.result SYNCHRONOUSLY the moment claude invoked Stop hook — before the JSONL tailer had a chance to read + post the preceding assistant text frame. Wire seq order: turn.result(N) → text(N+1) → usage(N+2). Mobile's _isAgentBusy walks tail-first: usage skip (v1.0.667), text → busy. turn.result at lower seq never reached. Fix: emit turn.result from the JSONL's own system{subtype: turn_duration} frame (LAST frame of a turn, written after text + stop_hook_summary). Now turn.result has the HIGHEST seq, walker hits it first, idle. Dropped turn.result emission from hookStop; FSM transition kept. - Token-flow pill: _TelemetryStrip gates on modelTotals.isNotEmpty, populated only from turn.result.by_model (codex/claude stream-json shape). M4's turn.result has no by_model. Pill suppressed even though usage was flowing. Fix: synthesise a _ModelTokens entry from per-message usage when modelTotals is empty. SET semantics on every field (NOT add) so the pre-v1.0.662 sum-across-tool-use-iterations bug doesn't reappear. Bucket key = per-message model or "claude-code". Tests: TestMapLine_TurnDurationSystemEmitsTurnResult (new mapper shape), TestOnHook_StopOnlyTransitionsFSM (rewrites the "hookStop emits turn.result" assertion to "hookStop posts nothing"). Latency note: turn.result lands ~250ms later than before (one tailer poll-tick after turn_duration frame written), in exchange for deterministic seq ordering against text/usage. Net win — the prior fast-but-out-of-order path left the cancel button stuck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 150e77d commit ff64b3c

8 files changed

Lines changed: 209 additions & 45 deletions

File tree

docs/changelog.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
> **Type:** reference
44
> **Status:** Current (2026-05-24)
55
> **Audience:** contributors, operators
6-
> **Last verified vs code:** v1.0.667
6+
> **Last verified vs code:** v1.0.668
77
88
**TL;DR.** Append-only record of what shipped in each tagged release.
99
One section per version, newest first. Format follows
@@ -23,6 +23,63 @@ binding). Seed entries prior to that are in
2323

2424
---
2525

26+
## v1.0.668-alpha — 2026-05-24
27+
28+
ADR-027 W11 fix-up wedge #12. Two M4 polish gaps surfaced after the
29+
v1.0.667 APK install — context-utilisation chip now renders fine,
30+
cancel-button-race + token-flow pill remain.
31+
32+
**Fixed.**
33+
34+
- *Cancel button stuck after MCP-tool turns.* `hookStop` posted
35+
`turn.result` SYNCHRONOUSLY the moment claude invoked the Stop
36+
hook — before the JSONL tailer had a chance to read + post the
37+
preceding assistant `text` frame. Wire seq order ended up
38+
`turn.result(N) → text(N+1) → usage(N+2)`, and mobile's
39+
`_isAgentBusy` walks tail-first: usage skip (v1.0.667), then
40+
text → return busy. turn.result at seq N was never reached.
41+
42+
Fix: emit `turn.result` from the JSONL's OWN
43+
`system{subtype:turn_duration}` frame — the LAST frame claude
44+
writes for a turn, after assistant text + stop_hook_summary.
45+
That guarantees turn.result has the HIGHEST seq of the turn,
46+
so the walker hits it first and flips to idle. Dropped the
47+
turn.result emission from `hookStop`; FSM transition kept.
48+
49+
- *Token-flow pill stayed blank.* `_TelemetryStrip` gates the
50+
pill on `modelTotals.isNotEmpty`, and the populating source is
51+
`turn.result.by_model` (codex/claude stream-json shape). M4's
52+
`turn.result` carries no `by_model`, so the pill stayed
53+
suppressed even though every assistant message had full usage.
54+
55+
Fix: after the events loop, synthesise a `_ModelTokens` entry
56+
from per-message usage when `modelTotals` is empty. SET semantics
57+
on every field (NOT add) so the pre-v1.0.662 sum-across-tool-use-
58+
iterations bug doesn't reappear. Bucket key = the per-message
59+
`model` (e.g. `claude-opus-4-7`) or `claude-code` if unknown.
60+
61+
**Test coverage.**
62+
63+
- `TestMapLine_TurnDurationSystemEmitsTurnResult` — locks the new
64+
mapper emission shape (kind/producer/reason/status/duration_ms/
65+
message_count).
66+
- `TestOnHook_StopOnlyTransitionsFSM` — replaces
67+
`TestOnHook_StopEmitsTurnResultForBusyWalker`; asserts hookStop
68+
no longer posts ANY event, only flips FSM state.
69+
70+
Mobile changes covered by CI flutter analyze.
71+
72+
**Note on the change of emission timing.** Pre-v1.0.668, turn.result
73+
landed within milliseconds of claude finishing a turn (hook is
74+
synchronous on engine side). Post-v1.0.668, it lands within one
75+
tailer poll-tick (≤250ms default) after claude writes the
76+
`system{turn_duration}` frame. Net: ~250ms added latency on the
77+
cancel-button flip, in exchange for correct ordering against
78+
text/usage. Acceptable — the alternative was the cancel button
79+
never flipping at all.
80+
81+
---
82+
2683
## v1.0.667-alpha — 2026-05-24
2784

2885
ADR-027 W11 fix-up wedge #11 — three residual M4 polish gaps surfaced

hub/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
// mobile and hub use the same x.y.z-alpha numbering. Use
1414
// `make bump VERSION=...` from the repo root to update both files
1515
// atomically.
16-
const Version = "1.0.667-alpha"
16+
const Version = "1.0.668-alpha"
1717

1818
var (
1919
Commit string

hub/internal/drivers/local_log_tail/claude_code/hooks.go

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -69,31 +69,27 @@ func (a *Adapter) dispatchHook(ctx context.Context, name string, payload map[str
6969

7070
// --- 7 observational handlers ---
7171

72-
func (a *Adapter) hookStop(ctx context.Context, p map[string]any) (map[string]any, error) {
72+
func (a *Adapter) hookStop(_ context.Context, _ map[string]any) (map[string]any, error) {
7373
if a.fsm != nil {
7474
a.fsm.Transition(StateIdle, "Stop hook")
7575
}
76-
final, _ := p["last_assistant_message"].(string)
77-
mode, _ := p["permission_mode"].(string)
78-
// turn.result is the canonical end-of-turn signal mobile's
79-
// _isAgentBusy() listens on (mobile skips `system` frames — they're
80-
// a grab-bag of telemetry — and only flips to idle on turn.result /
81-
// completion / session.init / certain lifecycle phases). Stop is
82-
// claude's engine-level end-of-turn, so it must produce a
83-
// turn.result for the UI contract to close. ACP and stream-json
84-
// drivers already do this; M4 was the holdout (fixed at v1.0.647
85-
// for agy with the same shape).
76+
// v1.0.668 dropped the turn.result emission this handler used to
77+
// own. Stop hook fires SYNCHRONOUSLY the moment claude finishes a
78+
// turn — which is BEFORE the JSONL tailer has had a chance to read
79+
// + post the assistant text frame that precedes the Stop
80+
// attachment in the file. The pre-v1.0.668 wire order was thus
81+
// `turn.result → text → usage` (turn.result got a lower seq than
82+
// text), and mobile's tail-first busy walker hit text → returned
83+
// busy → cancel button stuck.
8684
//
87-
// v1.0.661 dropped a sibling `system{subtype:turn_complete,…}`
88-
// emission: the data is the same as what turn.result already
89-
// carries, mobile had no renderer for the subtype, and the result
90-
// was a raw JSON blob in the transcript every turn.
91-
_ = a.post(ctx, "turn.result", "agent", map[string]any{
92-
"reason": "end_of_turn",
93-
"status": "success",
94-
"final_message": final,
95-
"permission_mode": mode,
96-
})
85+
// turn.result is now emitted by the mapper from the JSONL's own
86+
// `system{subtype:turn_duration}` frame (the LAST frame claude
87+
// writes for a turn). That guarantees turn.result has a higher seq
88+
// than any preceding text/tool_call/usage, so the walker hits it
89+
// first and flips to idle. See mapper.go::mapSystem("turn_duration").
90+
//
91+
// FSM still transitions to idle here so other adapter consumers
92+
// (none today, future probe contracts) see the correct state.
9793
return map[string]any{}, nil
9894
}
9995

hub/internal/drivers/local_log_tail/claude_code/hooks_test.go

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,18 @@ func TestOnHook_StopTransitionsToIdle_NoSystemTurnComplete(t *testing.T) {
103103
}
104104
}
105105

106-
// Stop hook MUST also emit turn.result so mobile's _isAgentBusy()
107-
// drops the cancel-on-send overlay. The busy walker skips `system`
108-
// frames (the turn_complete event lives there) and only flips to
109-
// idle on turn.result / completion / session.init / certain lifecycle
110-
// phases. Same fix shape as the agy v1.0.647 fix-up. Without
111-
// turn.result the cancel button sticks forever at end of every turn.
112-
func TestOnHook_StopEmitsTurnResultForBusyWalker(t *testing.T) {
106+
// v1.0.668: Stop hook no longer emits turn.result — the mapper does,
107+
// from the JSONL's own `system{subtype:turn_duration}` frame, which
108+
// always lands AFTER the assistant text frame so seq order is
109+
// monotonic. Previously hookStop posted turn.result synchronously the
110+
// moment claude invoked the hook, which raced the tailer: the wire
111+
// order ended up `turn.result → text → usage` (turn.result with lower
112+
// seq), and mobile's tail-first busy walker hit text → returned
113+
// busy → cancel button stuck. New invariant: hookStop only
114+
// transitions FSM, posts NOTHING. Coverage for turn.result emission
115+
// moved to the mapper tests
116+
// (TestMapLine_TurnDurationSystemEmitsTurnResult).
117+
func TestOnHook_StopOnlyTransitionsFSM(t *testing.T) {
113118
p := &hooksTestPoster{}
114119
a := hooksTestAdapter(t, p)
115120
a.fsm.Transition(StateStreaming, "seed")
@@ -121,21 +126,11 @@ func TestOnHook_StopEmitsTurnResultForBusyWalker(t *testing.T) {
121126
if err != nil {
122127
t.Fatalf("OnHook: %v", err)
123128
}
124-
ev, ok := findFirstByKind(p.snapshot(), "turn.result")
125-
if !ok {
126-
t.Fatalf("turn.result not emitted on Stop: %+v", p.snapshot())
127-
}
128-
if ev.producer != "agent" {
129-
t.Errorf("turn.result producer = %q; want agent", ev.producer)
130-
}
131-
if ev.payload["reason"] != "end_of_turn" {
132-
t.Errorf("turn.result reason = %v; want end_of_turn", ev.payload["reason"])
133-
}
134-
if ev.payload["status"] != "success" {
135-
t.Errorf("turn.result status = %v; want success", ev.payload["status"])
129+
if got := a.fsm.State(); got != StateIdle {
130+
t.Errorf("state = %v, want StateIdle", got)
136131
}
137-
if ev.payload["final_message"] != "Done." {
138-
t.Errorf("turn.result final_message = %v; want %q", ev.payload["final_message"], "Done.")
132+
if _, ok := findFirstByKind(p.snapshot(), "turn.result"); ok {
133+
t.Errorf("hookStop posted turn.result; v1.0.668 expects no posts at all: %+v", p.snapshot())
139134
}
140135
}
141136

hub/internal/drivers/local_log_tail/claude_code/mapper.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,39 @@ func mapSystem(subtype string, raw json.RawMessage) ([]MappedEvent, error) {
416416
Producer: "system",
417417
Payload: map[string]any{"subtype": "compact_boundary"},
418418
}}, nil
419+
case "turn_duration":
420+
// v1.0.668: claude's own end-of-turn marker. It's the LAST
421+
// frame claude writes for a turn (after assistant text +
422+
// stop_hook_summary), so emitting turn.result here guarantees
423+
// it lands on the hub with a higher seq than the preceding
424+
// text + usage. Mobile's busy-walker scans tail-first and
425+
// stops at the first turn.result/completion it finds, so
426+
// having turn.result be the LATEST event in the session is
427+
// what flips the cancel button off.
428+
//
429+
// Pre-v1.0.668 the only source of turn.result was the Stop
430+
// hook handler (hookStop posted immediately on hook fire),
431+
// which raced the tailer: hookStop's POST landed BEFORE the
432+
// tailer caught up to the assistant text frame, so the wire
433+
// order was turn.result → text → usage and the walker
434+
// returned busy. Caught on v1.0.667 dev-box smoke when the
435+
// cancel button stayed on after a multi-tool-use MCP turn.
436+
var s struct {
437+
Subtype string `json:"subtype"`
438+
DurationMs int `json:"durationMs"`
439+
MessageCount int `json:"messageCount"`
440+
}
441+
_ = json.Unmarshal(raw, &s)
442+
return []MappedEvent{{
443+
Kind: "turn.result",
444+
Producer: "agent",
445+
Payload: map[string]any{
446+
"reason": "end_of_turn",
447+
"status": "success",
448+
"duration_ms": s.DurationMs,
449+
"message_count": s.MessageCount,
450+
},
451+
}}, nil
419452
default:
420453
// Other system subtypes (debug telemetry, env diff, etc.)
421454
// are noise on mobile; drop.

hub/internal/drivers/local_log_tail/claude_code/mapper_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,41 @@ func TestMapLine_SystemOtherSubtypesDropped(t *testing.T) {
158158
}
159159
}
160160

161+
// v1.0.668: turn.result must be emitted from the JSONL's own
162+
// `system{subtype:turn_duration}` frame (the LAST frame claude
163+
// writes for a turn), not from the Stop hook handler. The hook
164+
// handler used to post turn.result synchronously the moment claude
165+
// invoked it — which raced the tailer that hadn't yet posted the
166+
// preceding assistant text frame, leaving turn.result with a lower
167+
// seq than text. Mobile's tail-first busy walker then hit text →
168+
// returned busy → cancel button stuck. Mapping from turn_duration
169+
// guarantees turn.result has the highest seq of the turn.
170+
func TestMapLine_TurnDurationSystemEmitsTurnResult(t *testing.T) {
171+
raw := `{"type":"system","subtype":"turn_duration","durationMs":3054,"messageCount":8}`
172+
got := mustMap(t, raw)
173+
if len(got) != 1 {
174+
t.Fatalf("want 1 event, got %d: %+v", len(got), got)
175+
}
176+
if got[0].Kind != "turn.result" {
177+
t.Errorf("kind = %q, want turn.result", got[0].Kind)
178+
}
179+
if got[0].Producer != "agent" {
180+
t.Errorf("producer = %q, want agent", got[0].Producer)
181+
}
182+
if got[0].Payload["reason"] != "end_of_turn" {
183+
t.Errorf("reason = %v, want end_of_turn", got[0].Payload["reason"])
184+
}
185+
if got[0].Payload["status"] != "success" {
186+
t.Errorf("status = %v, want success", got[0].Payload["status"])
187+
}
188+
if got[0].Payload["duration_ms"] != 3054 {
189+
t.Errorf("duration_ms = %v, want 3054", got[0].Payload["duration_ms"])
190+
}
191+
if got[0].Payload["message_count"] != 8 {
192+
t.Errorf("message_count = %v, want 8", got[0].Payload["message_count"])
193+
}
194+
}
195+
161196
func TestMapLine_KnownDroppedTypes(t *testing.T) {
162197
for _, ty := range []string{
163198
"permission-mode", "custom-title", "agent-name", "ai-title",

lib/widgets/agent_feed.dart

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,6 +1059,15 @@ class _AgentFeedState extends ConsumerState<AgentFeed> {
10591059
int? perMessageInput;
10601060
int? perMessageCacheRead;
10611061
int? perMessageCacheCreate;
1062+
// v1.0.668: also capture output + model so we can synthesise a
1063+
// _ModelTokens entry for the token-flow pill. M4 doesn't emit
1064+
// turn.result.by_model (the driver-of-record source for
1065+
// modelTotals), so the pill stayed blank even though every
1066+
// assistant message carried full usage. SET semantics here
1067+
// (overwrite, not sum) — same anti-double-count rule as the
1068+
// context-chip path.
1069+
int? perMessageOutput;
1070+
String? perMessageModel;
10621071
for (final e in _events) {
10631072
final kind = (e['kind'] ?? '').toString();
10641073
final p = e['payload'];
@@ -1117,6 +1126,12 @@ class _AgentFeedState extends ConsumerState<AgentFeed> {
11171126
if (i != null) perMessageInput = i;
11181127
if (cr != null) perMessageCacheRead = cr;
11191128
if (cc != null) perMessageCacheCreate = cc;
1129+
// v1.0.668: also capture output + model so we can synthesise
1130+
// a modelTotals entry below for the token-flow pill.
1131+
final o = (p['output_tokens'] as num?)?.toInt();
1132+
if (o != null) perMessageOutput = o;
1133+
final m = (p['model'] as String?);
1134+
if (m != null && m.isNotEmpty) perMessageModel = m;
11201135
// v1.0.667: pick up `context_window` if present. The M4
11211136
// mapper attaches it (derived from model name) so mobile can
11221137
// render the context-utilisation chip. Without it the chip
@@ -1135,6 +1150,39 @@ class _AgentFeedState extends ConsumerState<AgentFeed> {
11351150
if (modelTotals.isEmpty && cumulativeUsage != null) {
11361151
modelTotals[cumulativeBucketKey] = cumulativeUsage;
11371152
}
1153+
// v1.0.668: synthesise a modelTotals entry from per-message usage
1154+
// when no other source populated one. claude-code M4 doesn't emit
1155+
// turn.result.by_model, so without this the token-flow pill
1156+
// (which gates on modelTotals.isNotEmpty) stayed suppressed even
1157+
// though every assistant message carried full usage. SET
1158+
// semantics — `latestInput / latestCacheRead / latestCacheCreate`
1159+
// get the per-message snapshot directly, and we DO NOT increment
1160+
// `input / output / cacheRead / cacheCreate` (the cumulative
1161+
// fields), because per-message events would otherwise sum across
1162+
// a turn's many tool-use iterations and double-count by N× (the
1163+
// pre-v1.0.662 1M-tokens bug).
1164+
if (modelTotals.isEmpty &&
1165+
(perMessageInput != null || perMessageOutput != null)) {
1166+
final t = _ModelTokens.empty();
1167+
// Snapshot fields drive the chip; cumulative fields stay 0 so
1168+
// the SUM-on-display logic in _TelemetryStrip reads only the
1169+
// per-message values via billableInput / output.
1170+
t.latestInput = perMessageInput ?? 0;
1171+
t.latestCacheRead = perMessageCacheRead ?? 0;
1172+
t.latestCacheCreate = perMessageCacheCreate ?? 0;
1173+
// Token-flow pill reads `billableInput` (input + cacheCreate)
1174+
// and `output`. Populate them as snapshot too — they're meant
1175+
// to reflect what the user paid for on the LATEST message, not
1176+
// a session-wide aggregate that diverges from per-call usage.
1177+
t.input = perMessageInput ?? 0;
1178+
t.output = perMessageOutput ?? 0;
1179+
t.cacheRead = perMessageCacheRead ?? 0;
1180+
t.cacheCreate = perMessageCacheCreate ?? 0;
1181+
if (latestContextWindow != null && latestContextWindow! > 0) {
1182+
t.contextWindow = latestContextWindow!;
1183+
}
1184+
modelTotals[perMessageModel ?? 'claude-code'] = t;
1185+
}
11381186
// Claude path for context window: the codex `usage` event already
11391187
// populated latestContextWindow / latestContextUsed when present.
11401188
// For claude (which carries the data per-model on turn.result and

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
1616
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
1717
# In Windows, build-name is used as the major, minor, and patch parts
1818
# of the product and file versions while build-number is used as the build suffix.
19-
version: 1.0.667-alpha+10667
19+
version: 1.0.668-alpha+10668
2020

2121
environment:
2222
sdk: ^3.10.7

0 commit comments

Comments
 (0)