Skip to content

fix(run): preserve terminal usage provenance#97

Merged
byapparov merged 5 commits into
mainfrom
fix/issue-93-usage-provenance
Jul 20, 2026
Merged

fix(run): preserve terminal usage provenance#97
byapparov merged 5 commits into
mainfrom
fix/issue-93-usage-provenance

Conversation

@byapparov

@byapparov byapparov commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Closes #93
Closes #45

Intent

Make terminal assistant-message telemetry trustworthy by preserving completion state, usage provenance, and terminal-before-idle ordering.

Expected outcomes

  • Emit one terminal message_complete event per primary assistant message.
  • Distinguish completed, failed, and aborted terminal states.
  • Distinguish provider-reported, unavailable, and reserved estimated usage.
  • Preserve known usage when a later failure occurs and keep reported zero distinct from missing data.
  • Persist the terminal message before publishing session idle.

Expected impact

Billing, CI telemetry, and operational consumers can distinguish real provider usage from missing data and can stop on idle without losing the final message outcome. Child-session messages remain outside the primary-session terminal stream by design.

Summary

  • emit exactly one terminal message_complete per primary assistant message, keyed by stable messageID
  • add explicit completed, error, and aborted status plus reported, missing, and reserved estimated usage provenance
  • preserve provider-reported usage through later failures, represent unavailable tokens as null, and keep reported zero distinct
  • persist terminal assistant updates before publishing idle so headless consumers do not stop before failure or cancellation telemetry
  • document the additive schema-v1 fields and legacy provenance fallback

Verification

  • bun test test/cli/run-message-terminal.test.ts test/session/compaction.test.ts test/session/prompt-reliability.test.ts test/cli/usage-token-breakdown.test.ts — 56 pass
  • bun test --timeout 30000 — 1327 pass, 7 skip
  • bun run typecheck — pass
  • push hook bun turbo typecheck — 6/6 tasks pass
  • ./packages/sdk/script/build.ts — generated output unchanged
  • git diff --check — pass

Risk / compatibility

The NDJSON additions are additive for successful schema-v1 events. Failed and aborted terminal events now emit with tokens: null when provider usage is unavailable. Persisted messages written before this change infer reported when they have a finish reason and missing otherwise. The CLI does not fabricate estimated usage.

Comment thread packages/cli/src/cli/cmd/run.ts Outdated
})
const contextUsed = tokens.input + tokens.cache.read + tokens.cache.write
const context = buildContextWindow(contextLimit, contextUsed)
const contextLimit = args.attach

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 args.attach nulls contextLimit; undocumented behavior change.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #97, packages/cli/src/cli/cmd/run.ts:528-531):

Problem: args.attach nulls contextLimit; undocumented behavior change
Detail: The new `args.attach ? null : Provider.getModel(...)` short-circuit forces `context: null` for every attached run, even when the model is locally registered (claude-sonnet, glm-4.7, gpt-4) and `info.tokens` is fully reported. The pre-existing `.catch(Provider.ModelNotFoundError -> null)` already handles the genuinely-unregistered case, making the explicit `args.attach` branch redundant for that purpose. EVENTS.md's `context` section (updated in this same PR) enumerates the null cases as only "model limit not known" or "usage is missing" — attach mode is a third, unenumerated case, so the code contradicts the doc. The PR title and description ("preserve terminal usage provenance") do not mention any context-window behavior change, suggesting this is either unjustified scope creep or an unreviewed edit.
Suggested fix: Either drop the `args.attach ? null :` short-circuit and rely on the existing `ModelNotFoundError -> null` catch to return null only when the model is genuinely unregistered, or — if the attach-mode null is intentional (e.g. the local registry cannot vouch for the remote model) — document it as a third null case in EVENTS.md's `context` section and add a code comment explaining why.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new args.attach ? null : Provider.getModel(...) short-circuit forces context: null for every attached run, even when the model is locally registered (claude-sonnet, glm-4.7, gpt-4) and info.tokens is fully reported. The pre-existing .catch(Provider.ModelNotFoundError -> null) already handles the genuinely-unregistered case, making the explicit args.attach branch redundant for that purpose. EVENTS.md's context section (updated in this same PR) enumerates the null cases as only "model limit not known" or "usage is missing" — attach mode is a third, unenumerated case, so the code contradicts the doc. The PR title and description ("preserve terminal usage provenance") do not mention any context-window behavior change, suggesting this is either unjustified scope creep or an unreviewed edit.

                const contextLimit = args.attach
                  ? null
                  : await Provider.getModel(info.providerID, info.modelID)
                      .then((m) => m.limit.context)
                      .catch((e) => {
                        if (e instanceof Provider.ModelNotFoundError) return null
                        throw e
                      })

Comment thread packages/cli/src/session/prompt.ts Outdated
}
delete s[sessionID]
SessionStatus.set(sessionID, { type: "idle" })
// The loop's deferred cleanup publishes idle after the terminal assistant

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 cancel() drops idle; external callers deadlock consumers.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #97, packages/cli/src/session/prompt.ts:274-276):

Problem: cancel() drops idle; external callers deadlock consumers
Detail: The exported `SessionPrompt.cancel()` previously published `SessionStatus.set(sessionID, { type: "idle" })`. This PR removes that line and relocates idle publication to the single `using _ = defer(...)` scope inside the prompt loop. `cancel` is an exported symbol imported by run.ts, github.ts, session/index.ts, revert.ts, and tool/task.ts. Any caller of `cancel()` outside that one defer scope (e.g. `Instance.state()`'s dispose handler, signal handlers, session lifecycle cleanup) now silently drops the idle status. Headless SSE consumers that gate stop on `session.status: idle` — the exact pattern this PR hardens — will block indefinitely waiting for an idle event that never arrives. The added tests do not catch this: the mock SSE server in run-message-terminal.test.ts injects `session.status: idle` itself, and prompt-reliability.test.ts only asserts source-text ordering within the one defer scope.
Suggested fix: Either keep `SessionStatus.set(sessionID, { type: "idle" })` inside `cancel()` (the defer then becomes a harmless redundant superset that still publishes idle after the terminal update), or audit every caller of `cancel()` — including `Instance.state()`'s dispose handler and any abort/signal path — and ensure each publishes idle or routes through the defer. At minimum, add a behavioral test that calling `cancel(sessionID)` directly results in a `session.status: idle` event being observable.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The exported SessionPrompt.cancel() previously published SessionStatus.set(sessionID, { type: "idle" }). This PR removes that line and relocates idle publication to the single using _ = defer(...) scope inside the prompt loop. cancel is an exported symbol imported by run.ts, github.ts, session/index.ts, revert.ts, and tool/task.ts. Any caller of cancel() outside that one defer scope (e.g. Instance.state()'s dispose handler, signal handlers, session lifecycle cleanup) now silently drops the idle status. Headless SSE consumers that gate stop on session.status: idle — the exact pattern this PR hardens — will block indefinitely waiting for an idle event that never arrives. The added tests do not catch this: the mock SSE server in run-message-terminal.test.ts injects session.status: idle itself, and prompt-reliability.test.ts only asserts source-text ordering within the one defer scope.

    cb.reject(reason)
  }
  delete s[sessionID]
  // The loop's deferred cleanup publishes idle after the terminal assistant
  // update, so event consumers cannot stop before observing the aborted turn.
  return
}

Comment thread packages/cli/src/cli/cmd/run.ts Outdated
: {
total:
info.tokens.total ??
info.tokens.input + info.tokens.output + info.tokens.cache.read + info.tokens.cache.write,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 total token fallback omits the reasoning bucket.

--- a/packages/cli/src/cli/cmd/run.ts
+++ b/packages/cli/src/cli/cmd/run.ts
@@ -507,7 +507,7 @@
                     : {
                         total:
                           info.tokens.total ??
-                          info.tokens.input + info.tokens.output + info.tokens.cache.read + info.tokens.cache.write,
+                          info.tokens.input + info.tokens.output + info.tokens.reasoning + info.tokens.cache.read + info.tokens.cache.write,
                         input: info.tokens.input,
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #97, packages/cli/src/cli/cmd/run.ts:510-511):

Problem: total token fallback omits the reasoning bucket
Detail: The fallback total `info.tokens.input + info.tokens.output + info.tokens.cache.read + info.tokens.cache.write` omits the `reasoning` bucket. EVENTS.md states the five token buckets are non-overlapping ("a token is counted in exactly one bucket"), so a computed total must sum all five. The fallback undercounts by `reasoning` for extended-thinking models (o1, claude-with-thinking) when replaying legacy sessions where `info.tokens.total` was never persisted. It is also inconsistent with the sibling `getUsage` total computation in session/index.ts, which uses a different fallback formula — the two computed-total paths now disagree.
Suggested fix: Add `info.tokens.reasoning` to the sum so the computed total matches the documented non-overlapping five-bucket breakdown.

Suggested patch:
--- a/packages/cli/src/cli/cmd/run.ts
+++ b/packages/cli/src/cli/cmd/run.ts
@@ -507,7 +507,7 @@
                     : {
                         total:
                           info.tokens.total ??
-                          info.tokens.input + info.tokens.output + info.tokens.cache.read + info.tokens.cache.write,
+                          info.tokens.input + info.tokens.output + info.tokens.reasoning + info.tokens.cache.read + info.tokens.cache.write,
                         input: info.tokens.input,

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The fallback total info.tokens.input + info.tokens.output + info.tokens.cache.read + info.tokens.cache.write omits the reasoning bucket. EVENTS.md states the five token buckets are non-overlapping ("a token is counted in exactly one bucket"), so a computed total must sum all five. The fallback undercounts by reasoning for extended-thinking models (o1, claude-with-thinking) when replaying legacy sessions where info.tokens.total was never persisted. It is also inconsistent with the sibling getUsage total computation in session/index.ts, which uses a different fallback formula — the two computed-total paths now disagree.

                    : {
                        total:
                          info.tokens.total ??
                          info.tokens.input + info.tokens.output + info.tokens.cache.read + info.tokens.cache.write,
                        input: info.tokens.input,

processor.indexOf('if (needsCompaction) return "compact"'),
)
const failure = processor.slice(
processor.indexOf("} catch (e: any) {"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Source-text slice can pass failure assertion vacuously.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #97, packages/cli/test/session/prompt-reliability.test.ts:40-45):

Problem: Source-text slice can pass failure assertion vacuously
Detail: The new test slices processor.ts source text via `processor.indexOf("} catch (e: any) {")` to isolate the failure path. `indexOf` returns the FIRST match; processor.ts contains multiple catch sites (retry-loop catch and the outer failure catch), so if the first `} catch (e: any) {` in source order is not the intended failure catch, the `failure` window is wrong. Worse, if the catch syntax is ever tweaked (`} catch {` or `} catch (e) {`), `indexOf` returns -1, `slice(-1, X)` yields a near-empty string, and `expect(failure).not.toContain('type: "idle"')` passes vacuously — providing false assurance that the failure path no longer publishes idle. The same fragility applies to the `terminal` and `cleanup` slices if their anchor strings drift.
Suggested fix: Replace source-text slicing with a behavioral test (drive the processor through an error path with a fake SessionStatus recorder and assert no idle is published before updateMessage resolves). If source-text assertions must stay, anchor each slice with a unique marker, assert the window is non-empty before asserting contents, and use `lastIndexOf` or a comment sentinel to disambiguate multiple matches.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new test slices processor.ts source text via processor.indexOf("} catch (e: any) {") to isolate the failure path. indexOf returns the FIRST match; processor.ts contains multiple catch sites (retry-loop catch and the outer failure catch), so if the first } catch (e: any) { in source order is not the intended failure catch, the failure window is wrong. Worse, if the catch syntax is ever tweaked (} catch { or } catch (e) {), indexOf returns -1, slice(-1, X) yields a near-empty string, and expect(failure).not.toContain('type: "idle"') passes vacuously — providing false assurance that the failure path no longer publishes idle. The same fragility applies to the terminal and cleanup slices if their anchor strings drift.

    const failure = processor.slice(
      processor.indexOf("} catch (e: any) {"),
      processor.indexOf("input.assistantMessage.time.completed = Date.now()"),
    )

    expect(cleanup.indexOf("cancel(sessionID)")).toBeLessThan(cleanup.indexOf("SessionStatus.set(sessionID"))
    expect(failure).not.toContain('type: "idle"')

@aictrl-dev

aictrl-dev Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 2 · 🟡 2 · ⚪ 0 · 0/4 resolved

  • 🟡 packages/cli/src/cli/cmd/run.ts:510-511 — total token fallback omits the reasoning bucket
  • 🟠 packages/cli/src/cli/cmd/run.ts:528-531 — args.attach nulls contextLimit; undocumented behavior change
  • 🟠 packages/cli/src/session/prompt.ts:274-276 — cancel() drops idle; external callers deadlock consumers
  • 🟡 packages/cli/test/session/prompt-reliability.test.ts:40-45 — Source-text slice can pass failure assertion vacuously
🤖 Fix all 4 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #97 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/cmd/run.ts:510-511 — total token fallback omits the reasoning bucket
   Detail: The fallback total `info.tokens.input + info.tokens.output + info.tokens.cache.read + info.tokens.cache.write` omits the `reasoning` bucket. EVENTS.md states the five token buckets are non-overlapping ("a token is counted in exactly one bucket"), so a computed total must sum all five. The fallback undercounts by `reasoning` for extended-thinking models (o1, claude-with-thinking) when replaying legacy sessions where `info.tokens.total` was never persisted. It is also inconsistent with the sibling `getUsage` total computation in session/index.ts, which uses a different fallback formula — the two computed-total paths now disagree.
   Suggested fix: Add `info.tokens.reasoning` to the sum so the computed total matches the documented non-overlapping five-bucket breakdown.
2. packages/cli/src/cli/cmd/run.ts:528-531 — args.attach nulls contextLimit; undocumented behavior change
   Detail: The new `args.attach ? null : Provider.getModel(...)` short-circuit forces `context: null` for every attached run, even when the model is locally registered (claude-sonnet, glm-4.7, gpt-4) and `info.tokens` is fully reported. The pre-existing `.catch(Provider.ModelNotFoundError -> null)` already handles the genuinely-unregistered case, making the explicit `args.attach` branch redundant for that purpose. EVENTS.md's `context` section (updated in this same PR) enumerates the null cases as only "model limit not known" or "usage is missing" — attach mode is a third, unenumerated case, so the code contradicts the doc. The PR title and description ("preserve terminal usage provenance") do not mention any context-window behavior change, suggesting this is either unjustified scope creep or an unreviewed edit.
   Suggested fix: Either drop the `args.attach ? null :` short-circuit and rely on the existing `ModelNotFoundError -> null` catch to return null only when the model is genuinely unregistered, or — if the attach-mode null is intentional (e.g. the local registry cannot vouch for the remote model) — document it as a third null case in EVENTS.md's `context` section and add a code comment explaining why.
3. packages/cli/src/session/prompt.ts:274-276 — cancel() drops idle; external callers deadlock consumers
   Detail: The exported `SessionPrompt.cancel()` previously published `SessionStatus.set(sessionID, { type: "idle" })`. This PR removes that line and relocates idle publication to the single `using _ = defer(...)` scope inside the prompt loop. `cancel` is an exported symbol imported by run.ts, github.ts, session/index.ts, revert.ts, and tool/task.ts. Any caller of `cancel()` outside that one defer scope (e.g. `Instance.state()`'s dispose handler, signal handlers, session lifecycle cleanup) now silently drops the idle status. Headless SSE consumers that gate stop on `session.status: idle` — the exact pattern this PR hardens — will block indefinitely waiting for an idle event that never arrives. The added tests do not catch this: the mock SSE server in run-message-terminal.test.ts injects `session.status: idle` itself, and prompt-reliability.test.ts only asserts source-text ordering within the one defer scope.
   Suggested fix: Either keep `SessionStatus.set(sessionID, { type: "idle" })` inside `cancel()` (the defer then becomes a harmless redundant superset that still publishes idle after the terminal update), or audit every caller of `cancel()` — including `Instance.state()`'s dispose handler and any abort/signal path — and ensure each publishes idle or routes through the defer. At minimum, add a behavioral test that calling `cancel(sessionID)` directly results in a `session.status: idle` event being observable.
4. packages/cli/test/session/prompt-reliability.test.ts:40-45 — Source-text slice can pass failure assertion vacuously
   Detail: The new test slices processor.ts source text via `processor.indexOf("} catch (e: any) {")` to isolate the failure path. `indexOf` returns the FIRST match; processor.ts contains multiple catch sites (retry-loop catch and the outer failure catch), so if the first `} catch (e: any) {` in source order is not the intended failure catch, the `failure` window is wrong. Worse, if the catch syntax is ever tweaked (`} catch {` or `} catch (e) {`), `indexOf` returns -1, `slice(-1, X)` yields a near-empty string, and `expect(failure).not.toContain('type: "idle"')` passes vacuously — providing false assurance that the failure path no longer publishes idle. The same fragility applies to the `terminal` and `cleanup` slices if their anchor strings drift.
   Suggested fix: Replace source-text slicing with a behavioral test (drive the processor through an error path with a fake SessionStatus recorder and assert no idle is published before updateMessage resolves). If source-text assertions must stay, anchor each slice with a unique marker, assert the window is non-empty before asserting contents, and use `lastIndexOf` or a comment sentinel to disambiguate multiple matches.
📋 Out-of-diff findings (4)
Sev Location Finding
🟡 packages/cli/src/cli/cmd/run.ts:510-511 total token fallback omits the reasoning bucket
🟠 packages/cli/src/cli/cmd/run.ts:528-531 args.attach nulls contextLimit; undocumented behavior change
🟠 packages/cli/src/session/prompt.ts:274-276 cancel() drops idle; external callers deadlock consumers
🟡 packages/cli/test/session/prompt-reliability.test.ts:40-45 Source-text slice can pass failure assertion vacuously

Reviewed 10 files · 0 inline · view all 4 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov

Copy link
Copy Markdown
Contributor Author
Finding Verdict / action Resolution Verification
total fallback omits reasoning TRUE · FIX The legacy fallback now sums all five non-overlapping token buckets. Attached NDJSON regression omits total and observes computed 24.
attach forces null context TRUE · FIX Attached runs now resolve known limits from ModelsDev; local runs retain configured Provider lookup and unknown models still yield null. Attached zai/glm-4.7 regression observes { used: 18, limit: 204800 }.
cancel drops idle TRUE · FIX Cancellation distinguishes loop-owned state, where idle remains after terminal persistence, from shell-owned state, which publishes idle directly. Behavioral shell regression observes session.status: idle.
source slice is vacuous TRUE · FIX Removed the fragile processor source-window assertion; terminal telemetry and cancellation are covered through subprocess and session behavior. Focused behavioral suite: 28 pass, 0 fail.

Remediation commit: 323e610eb10fa217fe6e0fcd452066f5cdae37d9.

Verification:

  • bun test test/cli/usage-token-breakdown.test.ts test/cli/run-message-terminal.test.ts test/session/prompt-reliability.test.ts — PASS (28 tests, 73 assertions)
  • bun test --timeout 30000 — PASS (1,327 passed, 7 skipped, 0 failed)
  • bun run typecheck — PASS
  • push-hook workspace typecheck — PASS (6/6 tasks)

The push triggered one provider re-review and CI round for the new head.

Comment thread packages/cli/src/cli/cmd/run.ts Outdated
const contextUsed = tokens.input + tokens.cache.read + tokens.cache.write
const context = buildContextWindow(contextLimit, contextUsed)
const contextLimit = await (args.attach
? ModelsDev.get().then(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 ModelsDev.get() in attach path has no .catch.

--- a/packages/cli/src/cli/cmd/run.ts
+++ b/packages/cli/src/cli/cmd/run.ts
@@ -533,7 +533,7 @@
                 const contextLimit = await (args.attach
                   ? ModelsDev.get().then(
                       (providers) => providers[info.providerID]?.models[info.modelID]?.limit.context ?? null,
-                    )
+                    ).catch(() => null)
                   : Provider.getModel(info.providerID, info.modelID)
                       .then((m) => m.limit.context)
                       .catch((e) => {
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #97, packages/cli/src/cli/cmd/run.ts:534-542):

Problem: ModelsDev.get() in attach path has no .catch
Detail: The new contextLimit ternary treats the attach and non-attach paths asymmetrically. The non-attach path catches `Provider.ModelNotFoundError` and returns null (graceful degradation when the model is not in the registry). The attach path (`ModelsDev.get().then(providers => providers[info.providerID]?.models[info.modelID]?.limit.context ?? null)`) has NO .catch at all.

`ModelsDev.get()` reads a local registry file (AICTRL_MODELS_PATH). In `--attach` mode the user is connecting to an external server and may legitimately not have a models file configured; if it is missing, unreadable, or malformed, the promise rejects. The handler awaits this directly with no outer try/catch, so the rejection propagates out of the async `message.updated` handler. Worse, `ModelsDev.get()` is typically a cached singleton — the first rejection poisons every subsequent lookup for the rest of the run, suppressing all `message_complete` emissions in attach mode. The non-attach path degrades to `context: null`; the attach path should do the same.
Suggested fix: Add a `.catch(() => null)` to the attach branch mirroring the non-attach path's graceful degradation.

Suggested patch:
--- a/packages/cli/src/cli/cmd/run.ts
+++ b/packages/cli/src/cli/cmd/run.ts
@@ -533,7 +533,7 @@
                 const contextLimit = await (args.attach
                   ? ModelsDev.get().then(
                       (providers) => providers[info.providerID]?.models[info.modelID]?.limit.context ?? null,
-                    )
+                    ).catch(() => null)
                   : Provider.getModel(info.providerID, info.modelID)
                       .then((m) => m.limit.context)
                       .catch((e) => {

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The new contextLimit ternary treats the attach and non-attach paths asymmetrically. The non-attach path catches Provider.ModelNotFoundError and returns null (graceful degradation when the model is not in the registry). The attach path (ModelsDev.get().then(providers => providers[info.providerID]?.models[info.modelID]?.limit.context ?? null)) has NO .catch at all.

ModelsDev.get() reads a local registry file (AICTRL_MODELS_PATH). In --attach mode the user is connecting to an external server and may legitimately not have a models file configured; if it is missing, unreadable, or malformed, the promise rejects. The handler awaits this directly with no outer try/catch, so the rejection propagates out of the async message.updated handler. Worse, ModelsDev.get() is typically a cached singleton — the first rejection poisons every subsequent lookup for the rest of the run, suppressing all message_complete emissions in attach mode. The non-attach path degrades to context: null; the attach path should do the same.

                const contextLimit = await (args.attach
                  ? ModelsDev.get().then(
                      (providers) => providers[info.providerID]?.models[info.modelID]?.limit.context ?? null,
                    )
                  : Provider.getModel(info.providerID, info.modelID)
                      .then((m) => m.limit.context)
                      .catch((e) => {
                        if (e instanceof Provider.ModelNotFoundError) return null
                        throw e
                      }))

const info = event.properties.info
if (args.format === "json") {
if (info.finish) {
if (info.sessionID === sessionID && info.time.completed !== undefined && !emitted.has(info.id)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Child-session filter silently drops message_complete events.

Suggested change
if (info.sessionID === sessionID && info.time.completed !== undefined && !emitted.has(info.id)) {
Either document the scoping in EVENTS.md ("message_complete is emitted only for the primary session; child-session assistant messages are not reflected"), or broaden the filter to `info.sessionID === sessionID || childSessions.has(info.sessionID)` if child telemetry is desired.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #97, packages/cli/src/cli/cmd/run.ts:499):

Problem: Child-session filter silently drops message_complete events
Detail: The emit gate changed from `if (info.finish)` to `if (info.sessionID === sessionID && info.time.completed !== undefined && !emitted.has(info.id))`. The added `info.sessionID === sessionID` clause filters out assistant messages produced by child sessions (e.g. those spawned by the task tool). Previously, any assistant message with a finish reason — including child-session messages — produced a `message_complete` event. The new test explicitly asserts `msg_child` (sessionID: 'ses_child') is filtered out, so the scoping is intentional, but EVENTS.md does not document it.

Consumers that aggregate usage/cost across the primary and child sessions (e.g. for billing or CI telemetry) will silently stop receiving child-session `message_complete` events after this PR. EVENTS.md should state that `message_complete` is scoped to the primary session only, or the filter should be broadened to tracked `childSessions`.
Suggested fix: Either document the scoping in EVENTS.md ("message_complete is emitted only for the primary session; child-session assistant messages are not reflected"), or broaden the filter to `info.sessionID === sessionID || childSessions.has(info.sessionID)` if child telemetry is desired.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The emit gate changed from if (info.finish) to if (info.sessionID === sessionID && info.time.completed !== undefined && !emitted.has(info.id)). The added info.sessionID === sessionID clause filters out assistant messages produced by child sessions (e.g. those spawned by the task tool). Previously, any assistant message with a finish reason — including child-session messages — produced a message_complete event. The new test explicitly asserts msg_child (sessionID: 'ses_child') is filtered out, so the scoping is intentional, but EVENTS.md does not document it.

Consumers that aggregate usage/cost across the primary and child sessions (e.g. for billing or CI telemetry) will silently stop receiving child-session message_complete events after this PR. EVENTS.md should state that message_complete is scoped to the primary session only, or the filter should be broadened to tracked childSessions.

            if (args.format === "json") {
              if (info.sessionID === sessionID && info.time.completed !== undefined && !emitted.has(info.id)) {
                emitted.add(info.id)
                const usageStatus = info.usageStatus ?? (info.finish ? "reported" : "missing")

return adjustedInputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens
}
return input.usage.totalTokens
if (Number.isFinite(input.usage.totalTokens)) return input.usage.totalTokens

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Partial-sum total labeled usageStatus "reported".

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #97, packages/cli/src/session/index.ts:865-867):

Problem: Partial-sum total labeled usageStatus "reported"
Detail: When `input.usage.totalTokens` is not finite but `inputTokens` and `outputTokens` are, the `total` falls back to `inputTokens + outputTokens`, silently excluding reasoning/cache.read/cache.write tokens. That sum is a derived partial total, yet the message is still tagged `usageStatus: "reported"` (because usageStatus only requires ANY one of the six token fields to be finite). The schema enum in message-v2.ts declares `"estimated"` and EVENTS.md describes it as "an explicitly estimated future source", but no code path emits it. Downstream consumers (billing, accounting, telemetry) will treat a computed partial sum as a provider-reported total. Either emit `"estimated"` when the totalTokens-absent fallback branch is taken, or omit `total` on that branch.
Suggested fix: When the `return inputTokens + outputTokens` fallback is taken, mark the result as estimated by extending usageStatus logic (e.g. `totalReported ? "reported" : "estimated"`), or omit `total` so consumers see `tokens.total === undefined` for the partial case.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

When input.usage.totalTokens is not finite but inputTokens and outputTokens are, the total falls back to inputTokens + outputTokens, silently excluding reasoning/cache.read/cache.write tokens. That sum is a derived partial total, yet the message is still tagged usageStatus: "reported" (because usageStatus only requires ANY one of the six token fields to be finite). The schema enum in message-v2.ts declares "estimated" and EVENTS.md describes it as "an explicitly estimated future source", but no code path emits it. Downstream consumers (billing, accounting, telemetry) will treat a computed partial sum as a provider-reported total. Either emit "estimated" when the totalTokens-absent fallback branch is taken, or omit total on that branch.

          if (!Number.isFinite(input.usage.inputTokens) || !Number.isFinite(input.usage.outputTokens)) return
          return adjustedInputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens
        }
        if (Number.isFinite(input.usage.totalTokens)) return input.usage.totalTokens
        if (!Number.isFinite(input.usage.inputTokens) || !Number.isFinite(input.usage.outputTokens)) return
        return inputTokens + outputTokens
      })

cb.reject(reason)
}
delete s[sessionID]
if (match.loop) return false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 cancel() no longer publishes idle for loop sessions.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #97, packages/cli/src/session/prompt.ts:277-279):

Problem: cancel() no longer publishes idle for loop sessions
Detail: `cancel()` previously always transitioned the session to `{type:"idle"}`. After this PR, when `match.loop === true` it returns `false` and skips `SessionStatus.set(sessionID, {type:"idle"})`, relying on the `defer(() => { if (cancel(sessionID)) return; SessionStatus.set(sessionID, {type:"idle"}) })` block inside `loop()` to publish idle. That return-value contract is only honored by the loop's own defer.

`cancel` is an exported symbol. Any OTHER caller that cancels a loop-based session (e.g. an external cancel API, or a signal handler that calls cancel directly rather than going through `loop()`'s scope) will receive `false` and, unless it knows to set idle itself, the session remains visibly "busy" indefinitely — which can wedge headless consumers that gate teardown on the idle status. Idle publication is now structurally coupled to a return-value convention only one caller implements.
Suggested fix: Keep idle publication in `cancel()` unconditional for external callers; let the loop's defer skip it via an option, e.g. `cancel(sessionID, { suppressIdle: true })` for the internal loop-teardown path, defaulting to publishing idle.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

cancel() previously always transitioned the session to {type:"idle"}. After this PR, when match.loop === true it returns false and skips SessionStatus.set(sessionID, {type:"idle"}), relying on the defer(() => { if (cancel(sessionID)) return; SessionStatus.set(sessionID, {type:"idle"}) }) block inside loop() to publish idle. That return-value contract is only honored by the loop's own defer.

cancel is an exported symbol. Any OTHER caller that cancels a loop-based session (e.g. an external cancel API, or a signal handler that calls cancel directly rather than going through loop()'s scope) will receive false and, unless it knows to set idle itself, the session remains visibly "busy" indefinitely — which can wedge headless consumers that gate teardown on the idle status. Idle publication is now structurally coupled to a return-value convention only one caller implements.

    delete s[sessionID]
    if (match.loop) return false
    SessionStatus.set(sessionID, { type: "idle" })
    return true
  }

root: Instance.worktree,
},
cost: 0,
usageStatus: "missing",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

usageStatus field order diverges from Session.getUsage.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #97, packages/cli/src/session/compaction.ts:127):

Problem: usageStatus field order diverges from Session.getUsage
Detail: `Session.getUsage` returns `{ usageStatus, cost, tokens }` (usageStatus first), but the manually-constructed usage object in compaction.ts inserts `usageStatus` between `cost` and `tokens` (`{ cost: 0, usageStatus: "missing", tokens: { ... } }`). Both objects represent the same shape; aligning the field order would keep the two construction sites visually consistent and make future diffs easier to scan. Semantically equivalent.
Suggested fix: Reorder compaction.ts to `{ usageStatus: "missing", cost: 0, tokens: { ... } }` to match the getUsage return shape.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Session.getUsage returns { usageStatus, cost, tokens } (usageStatus first), but the manually-constructed usage object in compaction.ts inserts usageStatus between cost and tokens ({ cost: 0, usageStatus: "missing", tokens: { ... } }). Both objects represent the same shape; aligning the field order would keep the two construction sites visually consistent and make future diffs easier to scan. Semantically equivalent.

        root: Instance.worktree,
      },
      cost: 0,
      usageStatus: "missing",
      tokens: {
        output: 0,
        input: 0,

const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unsafe cast on untyped provider metadata.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #97, packages/cli/src/session/index.ts:834):

Problem: Unsafe cast on untyped provider metadata
Detail: `cacheWrite` is read from provider-supplied metadata via three consecutive `@ts-expect-error` accesses (untyped external data), then handed to `safe()` with an explicit `as number | undefined` cast. The cast asserts a type the runtime never validated at the access site. `safe()` happens to reject non-numbers (via `typeof value !== "number"`), so this is not exploitable, but the cast is misleading: it papers over the fact that the value originates from untrusted provider metadata. Prefer leaving it `unknown` and letting `safe()` narrow it, or validate explicitly.
Suggested fix: Drop the cast: `const cacheWriteInputTokens = safe(typeof cacheWrite === "number" ? cacheWrite : undefined)`, or have `safe()` accept `unknown`.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

cacheWrite is read from provider-supplied metadata via three consecutive @ts-expect-error accesses (untyped external data), then handed to safe() with an explicit as number | undefined cast. The cast asserts a type the runtime never validated at the access site. safe() happens to reject non-numbers (via typeof value !== "number"), so this is not exploitable, but the cast is misleading: it papers over the fact that the value originates from untrusted provider metadata. Prefer leaving it unknown and letting safe() narrow it, or validate explicitly.

      const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
      const cacheWriteInputTokens = safe(cacheWrite as number | undefined)

@aictrl-dev

aictrl-dev Bot commented Jul 19, 2026

Copy link
Copy Markdown

Code review

Verdict: Address the major findings before merging. · 🔴 0 · 🟠 1 · 🟡 3 · ⚪ 2 · 0/6 resolved

  • 🟡 packages/cli/src/cli/cmd/run.ts:499 — Child-session filter silently drops message_complete events
  • 🟠 packages/cli/src/cli/cmd/run.ts:534-542 — ModelsDev.get() in attach path has no .catch
  • packages/cli/src/session/compaction.ts:127 — usageStatus field order diverges from Session.getUsage
  • packages/cli/src/session/index.ts:834 — Unsafe cast on untyped provider metadata
  • 🟡 packages/cli/src/session/index.ts:865-867 — Partial-sum total labeled usageStatus "reported"
  • 🟡 packages/cli/src/session/prompt.ts:277-279 — cancel() no longer publishes idle for loop sessions
🤖 Fix all 6 open findings with your agent
Fix the following code review findings on aictrl-dev/cli PR #97 (head branch).
Run the relevant tests/linters after each change.

1. packages/cli/src/cli/cmd/run.ts:499 — Child-session filter silently drops message_complete events
   Detail: The emit gate changed from `if (info.finish)` to `if (info.sessionID === sessionID && info.time.completed !== undefined && !emitted.has(info.id))`. The added `info.sessionID === sessionID` clause filters out assistant messages produced by child sessions (e.g. those spawned by the task tool). Previously, any assistant message with a finish reason — including child-session messages — produced a `message_complete` event. The new test explicitly asserts `msg_child` (sessionID: 'ses_child') is filtered out, so the scoping is intentional, but EVENTS.md does not document it.

Consumers that aggregate usage/cost across the primary and child sessions (e.g. for billing or CI telemetry) will silently stop receiving child-session `message_complete` events after this PR. EVENTS.md should state that `message_complete` is scoped to the primary session only, or the filter should be broadened to tracked `childSessions`.
   Suggested fix: Either document the scoping in EVENTS.md ("message_complete is emitted only for the primary session; child-session assistant messages are not reflected"), or broaden the filter to `info.sessionID === sessionID || childSessions.has(info.sessionID)` if child telemetry is desired.
2. packages/cli/src/cli/cmd/run.ts:534-542 — ModelsDev.get() in attach path has no .catch
   Detail: The new contextLimit ternary treats the attach and non-attach paths asymmetrically. The non-attach path catches `Provider.ModelNotFoundError` and returns null (graceful degradation when the model is not in the registry). The attach path (`ModelsDev.get().then(providers => providers[info.providerID]?.models[info.modelID]?.limit.context ?? null)`) has NO .catch at all.

`ModelsDev.get()` reads a local registry file (AICTRL_MODELS_PATH). In `--attach` mode the user is connecting to an external server and may legitimately not have a models file configured; if it is missing, unreadable, or malformed, the promise rejects. The handler awaits this directly with no outer try/catch, so the rejection propagates out of the async `message.updated` handler. Worse, `ModelsDev.get()` is typically a cached singleton — the first rejection poisons every subsequent lookup for the rest of the run, suppressing all `message_complete` emissions in attach mode. The non-attach path degrades to `context: null`; the attach path should do the same.
   Suggested fix: Add a `.catch(() => null)` to the attach branch mirroring the non-attach path's graceful degradation.
3. packages/cli/src/session/compaction.ts:127 — usageStatus field order diverges from Session.getUsage
   Detail: `Session.getUsage` returns `{ usageStatus, cost, tokens }` (usageStatus first), but the manually-constructed usage object in compaction.ts inserts `usageStatus` between `cost` and `tokens` (`{ cost: 0, usageStatus: "missing", tokens: { ... } }`). Both objects represent the same shape; aligning the field order would keep the two construction sites visually consistent and make future diffs easier to scan. Semantically equivalent.
   Suggested fix: Reorder compaction.ts to `{ usageStatus: "missing", cost: 0, tokens: { ... } }` to match the getUsage return shape.
4. packages/cli/src/session/index.ts:834 — Unsafe cast on untyped provider metadata
   Detail: `cacheWrite` is read from provider-supplied metadata via three consecutive `@ts-expect-error` accesses (untyped external data), then handed to `safe()` with an explicit `as number | undefined` cast. The cast asserts a type the runtime never validated at the access site. `safe()` happens to reject non-numbers (via `typeof value !== "number"`), so this is not exploitable, but the cast is misleading: it papers over the fact that the value originates from untrusted provider metadata. Prefer leaving it `unknown` and letting `safe()` narrow it, or validate explicitly.
   Suggested fix: Drop the cast: `const cacheWriteInputTokens = safe(typeof cacheWrite === "number" ? cacheWrite : undefined)`, or have `safe()` accept `unknown`.
5. packages/cli/src/session/index.ts:865-867 — Partial-sum total labeled usageStatus "reported"
   Detail: When `input.usage.totalTokens` is not finite but `inputTokens` and `outputTokens` are, the `total` falls back to `inputTokens + outputTokens`, silently excluding reasoning/cache.read/cache.write tokens. That sum is a derived partial total, yet the message is still tagged `usageStatus: "reported"` (because usageStatus only requires ANY one of the six token fields to be finite). The schema enum in message-v2.ts declares `"estimated"` and EVENTS.md describes it as "an explicitly estimated future source", but no code path emits it. Downstream consumers (billing, accounting, telemetry) will treat a computed partial sum as a provider-reported total. Either emit `"estimated"` when the totalTokens-absent fallback branch is taken, or omit `total` on that branch.
   Suggested fix: When the `return inputTokens + outputTokens` fallback is taken, mark the result as estimated by extending usageStatus logic (e.g. `totalReported ? "reported" : "estimated"`), or omit `total` so consumers see `tokens.total === undefined` for the partial case.
6. packages/cli/src/session/prompt.ts:277-279 — cancel() no longer publishes idle for loop sessions
   Detail: `cancel()` previously always transitioned the session to `{type:"idle"}`. After this PR, when `match.loop === true` it returns `false` and skips `SessionStatus.set(sessionID, {type:"idle"})`, relying on the `defer(() => { if (cancel(sessionID)) return; SessionStatus.set(sessionID, {type:"idle"}) })` block inside `loop()` to publish idle. That return-value contract is only honored by the loop's own defer.

`cancel` is an exported symbol. Any OTHER caller that cancels a loop-based session (e.g. an external cancel API, or a signal handler that calls cancel directly rather than going through `loop()`'s scope) will receive `false` and, unless it knows to set idle itself, the session remains visibly "busy" indefinitely — which can wedge headless consumers that gate teardown on the idle status. Idle publication is now structurally coupled to a return-value convention only one caller implements.
   Suggested fix: Keep idle publication in `cancel()` unconditional for external callers; let the loop's defer skip it via an option, e.g. `cancel(sessionID, { suppressIdle: true })` for the internal loop-teardown path, defaulting to publishing idle.
📋 Out-of-diff findings (6)
Sev Location Finding
🟡 packages/cli/src/cli/cmd/run.ts:499 Child-session filter silently drops message_complete events
🟠 packages/cli/src/cli/cmd/run.ts:534-542 ModelsDev.get() in attach path has no .catch
packages/cli/src/session/compaction.ts:127 usageStatus field order diverges from Session.getUsage
packages/cli/src/session/index.ts:834 Unsafe cast on untyped provider metadata
🟡 packages/cli/src/session/index.ts:865-867 Partial-sum total labeled usageStatus "reported"
🟡 packages/cli/src/session/prompt.ts:277-279 cancel() no longer publishes idle for loop sessions

Reviewed 11 files · 0 inline · view all 6 findings ↗


aictrl · AI code review for fast-moving teams · aictrl.dev

@byapparov

Copy link
Copy Markdown
Contributor Author
Finding Verdict / action Resolution Verification
primary filter drops child events TRUE · FIX (documentation) #93 explicitly requires primary-session filtering. EVENTS.md now states that message_complete excludes child-session assistants and points consumers to child lifecycle events. Existing subprocess regression excludes msg_child; documentation regression verifies the contract.
attached ModelsDev.get() rejection TRUE · FIX Registry lookup failures now degrade to context: null without suppressing terminal events. Subprocess uses an unreachable registry and observes exit 0 plus msg_registry with null context.
compaction field order FALSE · IGNORE Object field order is semantically irrelevant and the construction is type-checked; changing it would add no behavior or contract value. Existing compaction coverage remains green.
unsafe provider metadata cast TRUE · FIX Token normalization now accepts unknown and performs runtime narrowing; the unvalidated cast was removed. Focused usage suite and typecheck pass.
partial sum labeled reported TRUE · FIX Generic providers without a reported total now preserve tokens.total as absent. Five-bucket total fallback is limited to legacy records without persisted provenance. Regressions cover partial reported usage with absent total and legacy fallback total 24.
loop cancel does not publish idle directly FALSE · IGNORE Every loop-owned state installs a deferred idle publisher before its first await. External cancellation aborts the loop, terminal persistence completes, then the defer publishes idle; publishing directly would reintroduce idle-before-terminal loss. PR #99 follows this same unwind path after stream abort. Terminal/cancellation behavioral suites pass; shell-owned state separately verifies direct idle publication.

Remediation commit: 72145fba3.

Verification:

  • bun test test/cli/usage-token-breakdown.test.ts test/cli/run-message-terminal.test.ts test/session/compaction.test.ts test/session/prompt-reliability.test.ts — PASS (59 tests, 138 assertions)
  • bun test --timeout 30000 — PASS (1,330 passed, 7 skipped, 0 failed)
  • bun run typecheck — PASS
  • push-hook workspace typecheck — PASS (6/6 tasks)

This is the second and final bounded remediation round; no further automated-review loop will be initiated.

@byapparov

Copy link
Copy Markdown
Contributor Author

CI remediation: the failed verify run was isolated to the new registry-failure test. CI bundles a models snapshot, so an unreachable network registry still resolved zai/glm-4.7 and correctly produced a non-null context.

Commit 69958f8bc extracts the existing catch behavior into attachedContextLimit and tests it with a directly rejected loader, making the regression deterministic across development and bundled CI environments. No telemetry semantics changed.

Verification after the adjustment:

  • focused suite — PASS (59 tests, 136 assertions)
  • full CLI suite — PASS (1,330 passed, 7 skipped, 0 failed)
  • CLI typecheck — PASS
  • push-hook workspace typecheck — PASS (6/6 tasks)

@byapparov byapparov added the enhancement New feature or request label Jul 20, 2026
@byapparov byapparov self-assigned this Jul 20, 2026
@byapparov

Copy link
Copy Markdown
Contributor Author

Readability revision: commit 48036a9e6 moves terminal usage normalization out of the event loop without changing behavior or schema.

Before: the loop combined provenance fallback, missing-usage branching, legacy five-bucket total reconstruction, and payload shaping in one nested expression.

After: terminalUsage(info) returns the cohesive { usageStatus, tokens } payload with an early return for missing usage; legacyTotal(info) names and isolates the compatibility-only five-bucket fallback. The loop now reads linearly: resolve usage, resolve context, emit.

Preserved by the existing behavioral coverage: primary-session filtering, reported zero, missing/reported provenance, partial totals remaining absent, legacy five-bucket total, attached context fallback, terminal status/deduplication, and terminal-before-idle ordering.

Verification:

  • focused message/usage tests — PASS (24 tests, 60 assertions)
  • full CLI suite — PASS (1,330 passed, 7 skipped, 0 failed)
  • package typecheck — PASS
  • workspace typecheck — PASS (6/6 tasks)
  • diff check — PASS

@byapparov
byapparov merged commit 82d003a into main Jul 20, 2026
5 checks passed
@byapparov
byapparov deleted the fix/issue-93-usage-provenance branch July 20, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Preserve unknown usage on failed headless turns bug: duplicate message_complete events in NDJSON output (--format json)

1 participant