Skip to content

feat(coding-agent): make a feedback report investigable - #297

Merged
liuedcson merged 6 commits into
mainfrom
feat/feedback-report
Oct 23, 2026
Merged

liuedcson merged 6 commits into
mainfrom
feat/feedback-report

Conversation

@liuedcson

Copy link
Copy Markdown
Collaborator

Why

A user feedback report used to carry their sentence, a session id pointing at a file on their machine, and a few build fields. Nothing in it let a maintainer reproduce the problem: no conversation, no tool calls, no error text, no model or context state. The agreed direction was to align with DeepSeek Harness — one submission carries the evidence, with no per-item selection, because asking users to assemble a report produced reports that were just their own sentence.

Base: main (3de15c16). All changes are in the product layer (packages/coding-agent/src/step/feedback), with the CLI and TUI as thin hosts.

What a report carries now

  • Build identity: versionSource (embedded / override / the 0.1.0 fallback a source build reports), buildTime, commit, runtime (node/electron/bun), arch, OS release, locale, install source.
  • Request provenance (step.usage_provenance): API dialect, catalog contextWindow/maxTokens at dispatch, and at settlement durationMs, ttftMs, wire outcome, HTTP status, who answered, and how far the model got.
  • Session summary (content-free): model, effort, turns, tool calls and failures, aborted/truncated attempts, model-request count, tokens, cost, cache hit rate, thinking time, per-compaction before/after sizes, plan/goal/task state, last turn's tool names, recent error kinds (with the provider's error text), cwd collapsed to ~.
  • Archive: the transcript (head and tail with a marker when oversized), the full tool-output files the transcript referenced, and the log tail.
  • Per-row metrics: each archived transcript row keeps its entry and gains a feedback object with that row's own tokens, cache hit rate, cost, timing and request id.

Consent and bounds

One review page, one decision. /feedback <text> now goes through the same path behind the same page; with no terminal the submission prints what it attached. The archive is only built when a bundle endpoint can receive it. A guessed session stays inside the current project's folder and respects the ten-minute recency window, and contributes neither transcript nor id when stale.

Redaction is one policy: credentials everywhere (scanned over the whole file so an early secret also redacts its later echoes), $HOME → ~ in the transcript, tool output, dev log, cwd and provider error text, plus absolute paths, e-mails and URLs in diagnostics.

Correctness notes worth reviewing

  • Summaries walk parentId from the last entry: session files are append-only trees, so counting every line reported rewound turns and a request id from an abandoned branch.
  • The per-row join runs in two passes because provenance entries are written after the message they describe.
  • pending-store re-validates a saved body strictly before retry. A field written but not allowlisted silently loses every offline report, so the new summary/request/build fields and the nested records are validated, and a stored archive carrying tool-output/ entries or a negative reclaimed is accepted.
  • Annotations are written to the bundle copy only; the session file is never rewritten (measured +2.2% uncompressed, +1.2–1.8% gzipped, once per submission).

Validation

  • packages/coding-agent: feedback suites + slash-command suites — 249 tests pass; full package 4935 pass with the 6 known completion-check-cli failures that also fail on clean main (environmental git fixture).
  • apps/cli: 746 tests pass (after building the workspace, which the suite requires).
  • packages/providers: 461 pass.
  • tsgo --noEmit, biome check --error-on-warnings, and the repository guards (ts-imports, layer-direction, no-secret-leak, public-boundary, no-observability, coding-agent-entry-freeze, pinned-deps) pass.

Docs

New docs/feedback-report.md (what a report carries, bounds, redaction, consent, retry, host contract), referenced from docs/AGENTS.md.

A feedback report used to carry the user's sentence, a session id pointing at
a file on their machine, and little else; the comment was the only thing a
maintainer could read. Everything needed to reproduce a problem was already on
disk but never reached the report.

One submission now carries the evidence, with no per-item selection: picking
what to attach produced reports that were just the user's own sentence.

- `step.usage_provenance` records the dispatch facts a later config change
  would hide: API dialect, catalog context window and output limit, and, at
  settlement, the request duration, time to first token, wire outcome, HTTP
  status, who answered, and how far the model got. A request the user cancelled
  because it looked stuck is therefore reportable (`durationSeconds`, `ttftMs:
  null`, `reached: "thinking"`).
- `session-summary.ts` derives the content-free summary from the session
  branch: model, effort, counts and failure classes, tokens, cost, cache hit
  rate, thinking time, per-compaction before/after sizes, plan/goal/task state,
  the last turn's tool names, and the newest request id. It walks `parentId`
  from the file's last entry, so abandoned turns and forks no longer distort
  the numbers; a pass no request followed yet is marked `unresolved` and left
  out of the size totals.
- `evidence.ts` gathers the summary, the log excerpt and the archive from one
  session so they cannot describe different conversations, and keeps the
  ten-minute recency rule for a guessed session — which now also stays inside
  the current project's folder instead of picking the newest file anywhere.
- The archive keeps the head and tail of an oversized transcript (with a marker
  between) and attaches the full tool output files the transcript referenced,
  resolved inside the session's own `tool-output` directory.
- Redaction is one policy: credentials everywhere (scanned over the whole
  file, so an early secret also redacts its later echoes), the home directory
  collapsed to `~` in the transcript, tool output, dev log, `cwd` and provider
  error text, and absolute paths, e-mails and URLs removed from diagnostics.
- Each archived transcript row keeps its entry and gains a `feedback` object
  with that row's own tokens, cache hit rate, cost, timing and request id. This
  is written to the bundle copy only; the session file is never rewritten, so
  the growth is +2.2% uncompressed and +1.2-1.8% gzipped, once, on submission.
- The report header names the build it came from: `versionSource` (embedded,
  override, or the `0.1.0` fallback a source build reports), `buildTime`,
  `runtime` (node/electron/bun), arch, OS release, locale and install source.
- `/feedback <text>` no longer bypasses the evidence path, and both hosts show
  the same consent page; with no terminal the submission prints what it
  attached. The archive is only built when a bundle endpoint can receive it.
- `pending-store` re-validates a saved body strictly before retry, including
  the new summary, request and build fields and the nested records, and accepts
  a stored archive that carries `tool-output/` entries or a negative
  `reclaimed` — a field written but not allowlisted would have silently lost
  every offline report.

Covered by new summary, row-metric and archive round-trip tests, plus updated
CLI/TUI interaction tests. `docs/feedback-report.md` states what a report
carries, its bounds, and the host integration contract.
Running the built CLI against a scratch session showed the home directory
surviving into the report two ways.

`$HOME` is often a symlinked prefix of the resolved path it appears in: with
`HOME=/var/...` and a resolved cwd of `/private/var/...`, replacing the first
occurrence produced `/private~/project` — a corrupted path that still names the
machine's directory layout. Matching now requires a boundary before the match
as well as after, and the resolved spelling of the home directory is collapsed
too, which is the spelling a tool prints after `realpath`.

A host that supplies no live facts also produced an empty `runtime: {}` in the
summary, which reads as "nothing to say" rather than "nothing to add"; the
field is omitted when empty.

Covered by the boundary cases in the summary tests and by a local end-to-end run
of the built CLI: body, archive, per-row metrics, offline retry and the
no-bundle-endpoint path.
@liuedcson

Copy link
Copy Markdown
Collaborator Author

Local end-to-end run (after the review rounds)

Ran the built CLI (apps/cli/dist/main.js) against a scratch storage root and a capture server, not just unit tests. Fixture: a session branch containing one rewound turn, one live turn, a compaction, and a cancelled request (ttftMs: null, reached: "thinking"), plus a full tool-output file and an error line in the dev log containing a token and an absolute path.

Verified on the actual wire:

  • Body — schemaVersion: 2, surface: "cli", versionSource: "fallback", runtime.node, request.requestId (newest, req-aborted), counts.turns: 1 (the rewound turn is excluded), usage / costUsd / cacheHitRate, compactions[0].tokensAfter: 42000, compactionTotals {passes:1, resolved:1, reclaimed:148000}, recentErrors[0] {kind:"aborted", durationMs:187000, ttftMs:null, reached:"thinking", outcome:"transport_error"}, cwd: "~/project".
  • Archive — events.jsonl, tool-output/tool-<hex>.txt, dev.log, bundle.json. The a1 row carries feedback: {turn:2, tokens:{...}, cacheHitRate:0.75, requestId:"req-live", durationMs:4200, ttftMs:640, thinkingMs:300}; the single-call tool result carries elapsedMs:1000; the cancelled row carries reached:"thinking", ttftMs:null.
  • Redaction — no $HOME substring in any archive entry, ~/code/app present instead, the token replaced by <redacted:secret> in transcript, tool output and dev log.
  • Offline + retry — a 500 saves pending-<id>.json + pending-<id>.tar.gz, exits non-zero and prints the path; feedback --retry posts body + archive and leaves the directory empty.
  • No bundle endpoint — only the body is posted and nothing is announced as attached.

Two real bugs surfaced only at runtime and are fixed in 1c92b0a4:

  1. HOME=/var/... with a resolved /private/var/... cwd produced /private~/project — the collapse matched a symlinked prefix mid-path. Matching now needs a boundary on both sides and also collapses the resolved spelling of home.
  2. A host with no live facts emitted an empty runtime: {} instead of omitting the field.

turn numbering counts the rows in the file (including a rewound turn), while counts.turns counts the live branch; both are now stated in docs/feedback-report.md because the difference is itself a signal that the user navigated back.

Re-ran afterwards: feedback + slash-command suites 249 pass, tsgo, biome --error-on-warnings, and the repository guards.

Not covered here: the desktop/web hosts — they live on feat/stepcode-desktop-mvp, not this branch. The TUI /feedback path is exercised by the handler tests with a stubbed UI, not by a manual terminal session.

@liuedcson

Copy link
Copy Markdown
Collaborator Author

TUI + real-model acceptance run

Beyond the CLI end-to-end run, I drove the real terminal host (node-pty, 110×40) and the RPC host against a real model (stepcode-anthropic / deepseek-v4.1-flash-api) with a mock collector, in an isolated storage root with only the credential path pointed at the user's store.

TUI /feedback <text> — consent overlay

The overlay renders before anything is sent, and now lists the fields a report actually carries:

Submit feedback?                      Preview 1-29 of 29
Category: other
Comment: the retry counter never resets after a failure
Identity sent with this feedback:
deviceId: (not set)   uid: 412516609951318016   username: (not set)
channel: dev          version: 0.1.0            versionSource: fallback
buildTime: (not set)  commit: (not set)         platform: darwin
arch: arm64           osRelease: 25.5.0         locale: (not set)
installSource: (not set)  sessionId: 01a1674e-…
runtime: node 24.21.0
surface: tui
… conversation transcript, session bundle (…): events.jsonl (…)
It contains the conversation itself: …

Confirming it submitted two requests; the captured body has surface: "tui", the request provenance (requestId, durationMs: 1418, ttftMs: 463, outcome: "ok"), the summary with live host facts (permissionPreset: "full-access", contextUsed, toolCount: 38), and the archived row carries its own cacheHitRate: 0.076, timing and request id. No $HOME, /Users/ or /private/var substring in any archive entry.

RPC host — real conversation + two real compactions

Four real turns, including two long file reads to get past keepRecentTokens, then two manual compactions (via the same session.compact() the terminal's /compact runs; compaction settings set in config.toml as [compaction] keepRecentTokens/contextProjection):

"compactions": [
  { "tokensBefore": 38370, "tokensAfter": 16512 },
  { "tokensBefore": 16598, "tokensAfter": 16302 }
],
"compactionTotals": { "passes": 2, "resolved": 2, "tokensBefore": 54968,
                      "tokensAfter": 32814, "reclaimed": 22154 }

The second pass is exactly the case the summary exists for: it compacted and reclaimed 296 tokens — previously invisible. counts.turns: 5, usage sums the live branch, cacheHitRate: 0.81 overall, and the archive's 11 annotated rows range from 0.079 to 0.996 per request, which is the per-request signal that a session average hides.

Both archives parse as JSONL, keep session/compaction/message/custom rows, and the transcript cwd is written as ~/workspace.

Two bugs were found by these runs and fixed in 1c92b0a4 (home-path collapse matching a symlinked prefix mid-path; an empty runtime: {}). The archive in the TUI run has no dev.log because that session logged no error — diagnostics only attach when there is an error line.

The idle-expiry case advanced 1ms of virtual time at a time, up to 100 times,
while waiting for a lane's final notification. That notification is sent only
after the child's details are read from disk, so the wait was really a race
against file I/O: it passes on an idle laptop and fails on a loaded CI runner,
where the assertion then failed on `inputs[1]` with a bare TypeError instead of
saying what happened.

Advance in 10ms steps for a bounded virtual budget, fail the wait explicitly
when it runs out, and let the assertion name the reply text and the
notifications that did arrive. Behaviour under test is unchanged.
`-C/--cwd` runs a session in one project while the process keeps its launch
directory, and `prepareFeedbackEvidence` falls back to `process.cwd()` when a
host names no project. A host that has the session (the terminal) therefore now
passes the session's own `cwd`, so a report can only ever describe the
conversation of the project it was filed from.

Covered by the resolution cases in `feedback-cwd-project.test.ts` and by the
terminal handler test: a session whose file does not exist yet contributes no
summary rather than a neighbouring project's conversation.
The context-projection case spawns the built CLI and SIGKILLs it after 20 s.
That guard exists to end a hang, but 20 s is also roughly a cold start on a
loaded CI runner, so the child was killed while still booting and the failure
read as a session with no requests rather than as a timeout.

Raise the guard to 90 s; the assertions are unchanged.
@liuedcson
liuedcson merged commit 81e79bc into main Oct 23, 2026
1 of 2 checks passed
@liuedcson
liuedcson deleted the feat/feedback-report branch October 23, 2026 06:28
@liuedcson

Copy link
Copy Markdown
Collaborator Author

Merged latest main, audited for conflicts and regressions

origin/main moved 3 commits since this branch's base (-C/--cwd, model selection saved as default on Enter, plan-mode tool block stability). Merged cleanly.

Conflict surface

30 files touched here, 27 by those three commits; the only overlap is apps/cli/src/main.ts — and in different regions (main wires -C into prepareMain's options near line 198; this branch passes runtime.permissionPreset to runFeedbackCommand near line 677). No conflict markers.

One real interaction, now fixed

-C/--cwd makes "the session's project" and "the process cwd" different. prepareFeedbackEvidence fell back to process.cwd() when a host named no project, so a /feedback filed in a -C session could have described a conversation from the launch directory.

  • The CLI path is unaffected: step feedback is a top-level subcommand, -C routes into prepareMain instead.
  • The terminal host is affected, so it now passes the session's own cwd (sessionSourceOf).
  • New test/feedback-cwd-project.test.ts (4 cases) pins resolution per project, including refusing to cross into another project's folder; the terminal handler test pins that a session without a file yet contributes no summary rather than a neighbour's conversation.

step-plan.ts was changed on main and is only read here; the plan-mode suite plus the new prompt-cache-prefix regression both pass after the merge.

Validation after merging

Scope Result
packages/coding-agent (full) 4971 pass; 6 fail — all in completion-check-cli, which also fails on clean main (environmental git fixture)
apps/cli (full, built) 748 pass
packages/agent-core / providers 539 / 461 pass
tsgo --noEmit, biome --error-on-warnings, 15 repo guards clean

Two CI flakes fixed separately (neither is feedback code)

  1. step-subagent-events: waited 100 × 1 ms of virtual time for a notification that is only sent after reading the child's details from disk — a race against I/O that a loaded runner loses (it failed as inputs[1] being undefined). Now advances in 10 ms steps for a bounded budget and fails with the notifications that did arrive.
  2. runtime-context-overrides: SIGKILLed the spawned CLI after 20 s, which is close to a cold start on CI, so the child was killed mid-boot. The guard now bounds a hang at 60 s and the case gets an explicit 120 s budget, so a genuine hang is reported with the child's output instead of a bare vitest timeout.

Neither change weakens an assertion.

Still open (not this PR)

Web/Desktop host (feat/stepcode-desktop-mvp) still assembles its own six-field payload and needs to move onto this pipeline after it merges main; server-side ingestion (requestId index, bundle unpacking, retention) needs backend scheduling.

liuedcson added a commit that referenced this pull request Oct 23, 2026
Brings the seven commits main gained since dff413f:

- feat(coding-agent): make a feedback report investigable (#297)
- fix(coding-agent): settle extension command turns before returning (#298)
- fix(coding-agent): keep the tool block stable across plan mode (#296)
- fix(cli): save model selection as default on Enter (#285)
- feat(cli): add -C/--cwd flag (#289)
- fix(coding-agent): preserve task-state prompt cache prefixes (#293)

Two conflicts, both in the plan extension, where this branch had added
behaviour on top of files main rewrote:

- `plan-mode-tools.ts`: main's comment for `exitPlanMode` ("restore any tools
  plan mode added") with this branch's optional `submittedPlanHash` parameter,
  which records the proposal handed to an external reviewer.
- `step-plan.ts`: main's `version: 2` entry and `carriesLegacyTools` migration
  path, plus this branch's `submittedPlanHash` / `planHashAtEnter` on the same
  entry, and both sides of the restore step (legacy tool baseline and the
  persisted hashes). Main's `resolveToCwd` guard and `planModeAddedTools`
  bookkeeping are kept as-is: plan mode now appends tools instead of replacing
  the list, which is what keeps the request's tool block stable.

The host plan-mode test asserted the old refusal wording; the guard now says
"Only the plan file may be written", so it matches on that.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant