Skip to content

feat: report a QuickAdd failure once, to the right person, with the message that helps - #1617

Merged
chhoumann merged 8 commits into
masterfrom
chhoumann/1601-error-reporting
Jul 27, 2026
Merged

feat: report a QuickAdd failure once, to the right person, with the message that helps#1617
chhoumann merged 8 commits into
masterfrom
chhoumann/1601-error-reporting

Conversation

@chhoumann

@chhoumann chhoumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Fixes #1601. Fixes #1602. Fixes #1603. Fixes #1604. Closes #1605.

Successor to #1606, built on its contract rather than forking it. That PR established three
rules - a dismissal is a typed error, a failure propagates, nothing floats - and filed the
five places where they are still not true. This is those five, plus the sibling defects that
turned up while proving each one.

The problem

QuickAdd could tell the user that something broke. It could not reliably tell them once,
whose fault it was, or what actually went wrong.

What a user sees

One failure, one notice - and it names the choice. A Macro whose user script throws.

Before After

A broken renderItem stops burying the user. Three items, three renders - nine stacked
notices, each message one prefix longer than the last, overflowing the window.

Before After

Escape stops being an error. Dismissing the one-page input modal raised a red 15-second
(ERROR) notice - a direct violation of #1606's own first rule, found while auditing the
handlers #1601 touches.

Before After

The design

Report-once belongs to reportError, not to each layer

Both layers in #1601 are right to report - neither can know whether anything above it will -
so the rule moved into the function they both call. reportError remembers what it has shown,
in a WeakSet keyed on identity, not text: the same failure through five layers reports
once, while two independent failures that happen to read alike still both report. It walks the
cause chain, because not every layer re-throws the same instance.

Two consequences I had to chase down, both found by review:

  • The innermost report wins, so it must carry the context. MacroChoiceEngine now names the
    choice as well as the script. That matters most where the outer handler is not there to help:
    a run-on-startup macro has no user action to correlate a notice with.
  • The wrapper can be the informative one. OpenAIRequest reported the raw provider error
    and threw a wrapper carrying the provider name and QuickAdd's context-window guidance -
    report-once dropped the wrapper. It reports the wrapper now, whose message is a strict superset.

Two more sites had the same doubling and did not reach either reporting layer: the user-script
load path (getUserScript throws straight past MacroChoiceEngine's catch, so a typo'd
require stacked two notices with the same 300-character message), and the AI request pair above.

Attribution: the topmost plugin frame decides

#1602 proposed requiring the first frame to be ours, and expected worse false negatives.
Measured, there are none. An Error constructed inside Obsidian's own async plumbing carries no
plugin frame at all
- not even the caller's:

Error: ENOENT: no such file or directory, open '…/definitely/missing/folder/x.md'
    at async open (node:internal/original-fs/promises:636:25)

so "any frame" was never catching that class either. The only thing it caught and this does not
is a foreign frame above ours - which is the false positive. QuickAdd's own bug reached
through another plugin still reports; verified live in both directions.

Two details the shape depends on:

  • The Name: message header is stripped, rather than filtering for at frames. QuickAdd is
    isDesktopOnly: false and Obsidian mobile runs JavaScriptCore, whose frames are
    fn@url:line:col with no at and no header. An at -keyed filter would have left this
    reporter dead on iOS with every desktop-shaped test still green. Both shapes are pinned.
  • The plugin id is read without requiring :line:col, because an eval'd frame names the
    bundle as an origin with no position - eval at exports.load (plugin:quickadd) is a user
    script, eval at <anonymous> (plugin:dataview) is a dataviewjs snippet. Those are exactly the
    frames attribution must not skip past.

The outcome carries the reason, at every exit

ChoiceOutcome's error variant gains reason, exactly as the abort path already does, and
both CLI sites forward it - the interactive bridge and runResolvedChoice, which serves
quickadd:run-template and quickadd:run verify and had the identical bug through a different
command. The URI x-callback handler keeps ignoring it on both variants, so no vault detail
leaks to an external callback URL.

Recorded at every failure exit, not just the top-level catch: five of the six were silent
returns producing the same reason-less outcome without any exception, so fixing only the catch
would have left #1603 reproducible on the more common paths. Review caught that my first pass
covered only the createNew branch, leaving Overwrite/Append - the paths taken whenever the
target note already exists - on the vague sentence.

Success is recorded at each engine's commit point precisely so a post-commit failure cannot make
an automation retry and duplicate the side effect, so that terminality lives in the recorder, not
in run(): Capture commits from two methods, and the canvas path continues into steps whose
throws unwind into run()'s catch.

An info cancel closes the panel; POST /abort ends the run

#1574 left the info divergence alone because cancelling was a client's only explicit way out.
So the protocol gets a real one first:

POST /abort?session=…&token=…  →  {"ok":true,"interrupted":<n>}

It rejects every pending prompt and marks the session so a later prompt rejects immediately -
a run that was mid-work unwinds at its next prompt. It pushes no final event: the run unwinds
through the ordinary cancellation path and delivers its real outcome, which is more truthful than
a fabricated one.

interrupted is there because the abort is not omnipotent, and I would rather say so at the wire
than gloss it: a Template/Capture run still opens some prompts in Obsidian itself which never
travel over this bridge (filed as #1614, reproduced live), so 0 tells a client it stopped nothing.

The info exception is decided in submitReply, which already tracks each prompt's type for
reply validation - not in RemotePromptProvider.infoDialog. A provider-side swallow cannot tell
a per-prompt cancel from a session abort without a second error class, and would leave /abort
unable to end a run blocked on an info panel, the one case it exists for.

The handshake carries "capabilities":["abort"]. Both halves are observable changes to a
documented wire, and without a marker a client could only feature-detect by string-matching a 404
body. This is why the PR is feat: - a new public route plus a reversed reply semantic is not
a patch, even though everything in it is a bug fix.

One toError, one warner

logManager had a second toError - a context-less twin of the safe one. Its existence is why
#1604 happened: the two suggesters imported that one, so the safe helper's "do NOT mutate the
caller's Error" contract did not apply to the call sites that most needed it, and they hand-rolled
the mutation it forbids. Deleted; one helper now.

The notice storm is fixed at the same seam (createRenderFallbackWarner), which also covers the
choice picker's Markdown-render fallback - the same per-row-per-keystroke shape at logError
severity.

Validation

Everything below ran in this worktree's own isolated vault (Obsidian 1.13.0), comparing a build of
f9f158d0 against this branch in the same instance.

before after
user script throws, via its command 2 notices 1, naming the script and the choice
user script fails to load 2 notices (same 300-char message) 1
Escape on the one-page input modal 1 red (ERROR) notice none
foreign plugin's bug floated through quickadd.api.suggester claimed as ours left alone
QuickAdd's own bug reached via a foreign plugin reported still reported
broken renderItem, 3 items × 3 renders 9 stacked, compounding 1, caller's Error unmutated
quickadd:interactive on a missing template Choice execution failed; no file was created. Template file not found at path "templates/does-not-exist.md".
quickadd … verify on the same same fixed sentence same real message
Overwrite mode, missing template vague sentence + 2 notices real cause + 1 notice
remote info cancel run aborted, steps skipped panel closes, run finishes done

Plus, over real loopback HTTP: /abort200 {"interrupted":1} then
error: Input cancelled by user; /abort on an ended session → 409; GET /abort404;
bad token → 404; browser Origin403.

tsc, eslint, and 4502 unit tests green. New ratchets verified to fail on revert: the two
attribution tests fail against the old "any frame" rule, and the JavaScriptCore test fails against
an at -prefix filter.

Review changed the design, twice

Both an adversarial design review (before coding) and an implementation review (before this PR)
found things I had wrong:

  • The design's /^\s*at / frame gate would have silently killed the reporter on iOS.
  • Report-once picked the least informative message on the AI path - a regression this PR
    introduced and this PR removes.
  • My first [BUG] An interactive Template/Capture run hides the real error from the remote client #1603 pass covered 2 of 6 failure exits, then 5 of 6.
  • /abort left queued prompt events in place, so the next poll could hand a client the very
    dialog it had just cancelled, in front of the run's real outcome.
  • The interactive server's own tests leaked sessions and were three tests from failing on
    MAX_SESSIONS as a capacity error blamed on whichever test came 33rd.

Known limitations

  • A user script that throws a bare string still doubles. A WeakSet cannot key a primitive,
    and normalising it at the throw boundary would break the legacy string-sentinel cancellation
    contract isCancellationError still honours for user scripts. Pinned by a test that says so,
    rather than left to be rediscovered.
  • StartupMacroEngine's "Startup macro 'X' failed" wrapper is dropped when an inner layer
    reported first. Mitigated by the inner report now naming the choice; "at startup" is the part
    that is lost, and it is inferable from an unprompted notice at launch.
  • /abort cannot reach prompts QuickAdd opens in Obsidian on a Template/Capture run ([BUG] An interactive run still opens some prompts in Obsidian instead of routing them to the client #1614).
  • An Error constructed inside Obsidian's async plumbing and then floated is still missed - measured,
    unchanged, and not something the old rule caught either.

Filed rather than folded in

#1614 (an interactive run still opens some prompts in Obsidian) · #1615 (a capture that commits
nothing is reported as a verified success).

Summary by CodeRabbit

  • New Features

    • Added support for ending interactive runs via an abort request.
    • Interactive clients now explicitly receive abort capability signaling.
    • Updated cancellation behavior so dismissing informational prompts closes the prompt without aborting the run.
  • Bug Fixes

    • Interactive and CLI flows now surface clearer, actionable failure messages when a specific reason is available.
    • Prevented duplicate error notifications.
    • Clarified reply error behavior when no matching prompt is pending, and improved error attribution to reduce misleading noise.
  • Documentation

    • Updated interactive bridge guidance for cancel vs abort, including /abort and /reply semantics and response handling.

…s the user

`toError(err)` with no context returns the CALLER's Error unchanged, and both
suggesters then assigned to `err.message`. `renderSuggestion` runs once per visible
row and re-runs on every keystroke, so a `renderItem` that rethrows one cached Error
grew the prefix without bound - and because `log.logWarning` raises a Notice, it did
so once per row per keypress. Measured live in the isolated vault: three items over
three renders put NINE stacked notices on screen, the last carrying nine copies of
"Custom renderItem threw an error; falling back to default rendering. " (#1604).

Both halves fixed at one seam, `createRenderFallbackWarner`: the context goes to
`toError`, which builds a fresh Error preserving name and stack - exactly what
`toError`'s own docstring says it stopped doing for this reason - and the warner
fires once per call site. One broken callback is one defect, and the fallback
rendering it triggers is identical for every row.

Also deletes the second `toError` in logManager. Its existence is why this happened:
the two suggesters imported THAT one, a context-less twin, so the safe helper's
"do NOT mutate the caller's Error" contract did not apply to the call sites that
most needed it. One helper now.

Refs #1604
One user-script failure raised TWO 15-second notices for one bug (#1601):

    QuickAdd: (ERROR) Failed to run user script probe.js: Cannot read properties…
    QuickAdd: (ERROR) Error executing choice probe-1601: Cannot read properties…

MacroChoiceEngine reports and re-throws, and the command-palette handler reports
again. Both layers are right to report - neither can know whether anything above it
will - so "report once" now belongs to the function they both call. `reportError`
remembers the values it has shown, keyed on identity rather than text, so the same
failure through five layers reports once while two independent failures that happen
to read alike still both report. It walks the `cause` chain, because not every layer
re-throws the same instance: the AI request path reports the provider error and then
throws a wrapper carrying it as `cause` (both its sites report through `reportError`
now, so that pair collapses too).

The user-script LOAD path had the same doubling and reached neither reporting layer -
`getUserScript` throws straight past MacroChoiceEngine's catch - so a typo'd `require`
stacked two notices carrying the same 300-character message. It reports through
`reportError` now.

Also fixes what the outermost handlers did with a dismissal. Escape on the one-page
input modal raised, live:

    QuickAdd: (ERROR) Error executing choice onepage-s2: One-page input cancelled by user

- a red 15-second notice for a deliberate Escape, which is exactly what PR #1606's
first rule exists to prevent. `reportUnlessCancelled` is that rule at the three sites
that catch a whole run: the command callback, the legacy URI path, and the picker.
Those handlers now name the choice instead of its UUID, and the picker's two
template-stringified `log.logError` calls became real reports - interpolating an error
throws its stack away, and only a value passed through `reportError` can take part in
report-once.

Refs #1601
The reporter claimed a rejection if `plugin:quickadd:` appeared ANYWHERE in the stack.
`Error.stack` is captured at construction with the whole live call stack, so a foreign
caller's bug that merely ran through QuickAdd was claimed as ours - and `preventDefault`
took away the console line naming the real culprit (#1602). Reproduced with a second
plugin calling `quickadd.api.suggester(v => v.nope.trim(), items)` and floating it:

    TypeError: Cannot read properties of undefined (reading 'trim')
        at eval (plugin:probe-foreign:19:43)      <- construction site: THEIRS
        at eval (plugin:quickadd:88:3004)
        at r.suggester (plugin:quickadd:88:2988)
        at Object.callback (plugin:probe-foreign:19:19)

The topmost frame naming ANY plugin bundle now decides. The issue proposed requiring
the first frame to be ours and expected worse false negatives; measured, there are
none. An Error constructed inside Obsidian's own async plumbing (`vault.create` into a
missing folder, awaited from a plugin) carries no plugin frame at all - not even the
caller's - so "any frame" was never catching that class either:

    Error: ENOENT: no such file or directory, open '…/definitely/missing/folder/x.md'
        at async open (node:internal/original-fs/promises:636:25)

The only thing "any frame" caught and this does not is a foreign frame above ours,
which is the false positive itself. QuickAdd's own bug reached through another plugin
still reports, verified live.

Two details the shape depends on:

- The `Name: message` header is stripped instead of filtering for `at ` frames.
  QuickAdd is `isDesktopOnly: false` and Obsidian mobile runs JavaScriptCore, whose
  frames are `fn@url:line:col` with no `at ` and no header - an `at `-keyed filter
  would have left this whole reporter dead on iOS with every desktop-shaped test
  still green. Stripping the header still stops an Error naming a plugin in its own
  message from dictating the blame. Both shapes are pinned by tests.
- The plugin id is read without requiring a trailing `:line:col`, because an eval'd
  frame names the bundle as an origin with no position: `eval at exports.load
  (plugin:quickadd)` is a user script's own code and `eval at <anonymous>
  (plugin:dataview)` is a dataviewjs snippet. Those are exactly the frames
  attribution must not skip past. `plugin:quickadd-beta` still reads as a different
  plugin.

`preventDefault` is now gated on a report actually going out, since `reportError` can
drop a failure a lower layer already showed the user - claiming the event anyway would
kill the browser's async trace and put nothing in its place, the opposite of the
guarantee the repeat-window branch keeps.

Refs #1602
On an interactive run - the seam whose entire premise is that nobody is at the
desktop - a genuine failure reached the client as:

    {"kind":"error","error":"Choice execution failed; no file was created."}

while the actionable sentence (`Template file not found at path "templates/x.md".`)
went to a notice on a screen nobody was watching (#1603). The Template and Capture
engines report a failure and return WITHOUT recording an outcome, so
`executeWithOutcome` produced a reason-less `{status:"error"}` and the CLI
substituted a fixed sentence. The Macro branch already forwarded `error.message`;
only Template and Capture lost it.

`ChoiceOutcome`'s error variant now carries `reason`, exactly as the abort path
already does for `cancelKind:"aborted"`, and both CLI sites forward it - the
interactive bridge and `runResolvedChoice`, which serves `quickadd:run-template` and
`quickadd:run verify=…` and had the identical bug through a different command. The
URI x-callback handler keeps ignoring it on both variants, so no vault detail leaks
to an external callback URL.

The reason is recorded at EVERY failure exit, not just the top-level catch. Five of
the six were silent returns - a missing append-link destination, a target path
occupied by a non-markdown file, an unresolvable file-exists behaviour, a failed
create - each producing the same reason-less outcome without any exception, so
fixing only the catch would have left #1603 reproducible on the more common paths.
The fixed sentence is now a fallback for an outcome that carries nothing, which
nothing in the engines produces.

Two details worth naming:

- Success is recorded at each engine's commit point precisely so a post-commit link
  or open-file failure cannot make an automation retry and duplicate the side effect.
  That terminality lives in the recorder, not in `run()`: Capture commits from two
  methods, and the canvas path keeps going into steps whose throws unwind into
  `run()`'s catch.
- `createFileWithTemplate` reports the real cause and returns null, so its caller
  knew only THAT creation failed. It now hands the cause up, and the caller records
  it without logging a second, vaguer line.

Verified live in the isolated vault - a Template choice pointing at a missing
template, over real loopback HTTP and via the verified CLI path - both now answer
`Template file not found at path "templates/does-not-exist.md".`, and the desktop
shows one notice saying the same thing.

Refs #1603
…abort does

`promptProvider.ts` opens by claiming each method "returns exactly what its in-app
counterpart returns, so a script cannot tell it was driven remotely". `infoDialog`
broke that. `GenericInfoDialog` resolves on EVERY close path and has no reject path
at all, so it can never abort anything - but remotely, replying `{"cancelled":true}`
to an `info` prompt rejected with `UserCancelError` and ended the run. Escape is the
only gesture an info panel affords, and a client maps it to a cancel, so the same
choice that finishes in the app died remotely (#1605). Measured both ways in the
isolated vault before the fix: in-app Escape ran the macro to completion; the remote
cancel returned `{"kind":"error","error":"Input cancelled by user"}` with the
remaining steps never run.

#1574 documented this instead of changing it, because cancelling was a client's only
explicit way out mid-run. So the protocol gets a real one first:

    POST /abort?session=…&token=…  ->  {"ok":true,"interrupted":<n>}

It rejects every pending prompt with `UserCancelError` and marks the session, so a
prompt raised afterwards rejects immediately rather than parking - a run that was
mid-work unwinds at its next prompt. It pushes no final event: the run unwinds
through the ordinary cancellation path and delivers its REAL outcome, which is more
truthful than a fabricated one. `interrupted` is there because the abort is not
omnipotent: a Template/Capture run still opens some prompts in Obsidian itself (the
file-exists chooser, the folder picker, the capture-target picker) which never travel
over this bridge, so `0` tells a client it stopped nothing. That limit is documented
at the wire rather than glossed.

The info exception is decided in `submitReply`, which already tracks each prompt's
type for reply validation - not in `RemotePromptProvider.infoDialog`. A provider-side
swallow cannot tell a per-prompt cancel from a session abort without a second error
class, and would leave `/abort` unable to end a run blocked on an info panel, the one
case it exists for.

The handshake now carries `"capabilities":["abort"]`. Both halves of this are
observable behaviour changes to a documented wire, and without a marker a client
could only feature-detect by string-matching a 404 body - an unknown path and an
unauthed session answer the same shape.

Verified over real loopback HTTP: info cancel -> `200`, panel closes, run finishes
`done`; `/abort` -> `200 {"interrupted":1}` then `error: Input cancelled by user`;
`/abort` on an ended session -> `409`; `GET /abort` -> `404`; bad token -> `404`;
browser `Origin` -> `403`.

Closes #1605
Adversarial review of the four commits above found three ways the report-once rule
picked the wrong survivor, and one way `/abort` misled its client.

**The AI request path lost its remedy.** `OpenAIRequest` reported the raw provider
error and then threw a wrapper carrying the provider name and QuickAdd's own
context-window guidance ("shorten it, choose a model with a larger context, or use the
chunked AI prompt API"). Report-once then dropped the wrapper as an already-reported
cause, so a context-overflow failure showed only the bare provider string - the least
informative half of a pair that used to show both. It now reports the WRAPPER, whose
message is a strict superset, and the test asserts the logged message rather than a
call count.

**A macro failure did not say which macro.** The surviving report is the innermost
one, so it has to carry enough context on its own. `MacroChoiceEngine` now names the
choice alongside the script. That matters most where the outer handler is not there to
help: a run-on-startup macro fails with no user action to correlate it with, and
"Failed to run user script sync.js" alone does not say which of two startup macros
broke.

**#1603 was only fixed for one file-exists branch.** `lastTemplateFileFailure` was set
by `createFileWithTemplate` alone, so Overwrite and Append - the paths taken whenever
the target note already exists - still answered `Could not resolve file exists
behavior for 'x.md'.` and still raised a second, vaguer notice. All three write
helpers record their cause now. Verified live: a Template choice set to Overwrite with
a missing template now answers `Template file not found at path "templates/gone.md".`
with exactly one notice, where it previously gave the vague sentence plus two notices.

**`/abort` could hand back a prompt it had just cancelled.** A prompt raised while no
poll was parked sits in `session.queue`; aborting rejected the pending map but left
the queue, so the next poll delivered a dialog the client had just asked to cancel,
for a dead requestId, in front of the run's real terminal event. Queued prompts are
dropped now; a queued done/error is kept, because that is the outcome the client is
waiting for.

Also from the review:

- `/abort` is now driven through the real router in tests - POST 200 with
  `interrupted`, 409 on an ended session, 404 for GET and for a bad token - instead of
  calling `abortSession` directly, so the routing and status mapping this PR
  documents are actually pinned. The handshake's `capabilities` marker is asserted too.
- The docs no longer promise `/abort` produces an `error` event: a run with nothing
  left to interrupt finishes normally and delivers `done`, side effects committed.
  `/abort`'s 409 and 404 responses are documented, and the `409` sentence further down
  is scoped to `/reply`.
- `promptProvider`'s opening paragraph still claimed every method rejects on a client
  cancel, contradicting the paragraph below it.
- The interactive server's tests leaked sessions (they live for 60s after `finish`),
  so the file was three tests away from failing on MAX_SESSIONS as a capacity error
  attributed to whichever test came 33rd.
- Dead surface removed: Capture's `failRun` level parameter no caller passes, and
  `dedupeKey`'s message fallback, unreachable since attribution requires a frame.

Refs #1601, #1602, #1603, #1605
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 27, 2026

Copy link
Copy Markdown

Deploying quickadd with  Cloudflare Pages  Cloudflare Pages

Latest commit: 380f915
Status: ✅  Deploy successful!
Preview URL: https://a4a0f4df.quickadd.pages.dev
Branch Preview URL: https://chhoumann-1601-error-reporti.quickadd.pages.dev

View logs

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1a932cfe-c466-4f71-805d-b2dfbc955bfc

📥 Commits

Reviewing files that changed from the base of the PR and between bdf586a and 380f915.

📒 Files selected for processing (2)
  • src/interactive/interactivePromptServer.test.ts
  • src/interactive/interactivePromptServer.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/interactive/interactivePromptServer.test.ts
  • src/interactive/interactivePromptServer.ts

📝 Walkthrough

Walkthrough

The PR centralizes error reporting, propagates choice failure reasons, adds committed outcome recording, introduces interactive session abort semantics, and de-duplicates suggester render warnings. Documentation and tests cover the updated protocols and failure paths.

Changes

Error reporting and attribution

Layer / File(s) Summary
Centralized report-once behavior
src/utils/errorUtils.ts, src/utils/errorUtils.test.ts, src/logger/logManager.ts
Error reporting tracks duplicate values and causes, suppresses cancellation notices, returns reporting status, and consolidates toError.
Caller error reporting and attribution
src/ai/*, src/main.ts, src/utils/userScript.ts, src/engine/MacroChoiceEngine.ts, src/utils/unhandledRejectionReporter.*, src/quickAddApi.ts
Callers use contextual, cancellation-aware reporting; unhandled rejections are attributed to parsed plugin stack frames and suppress browser handling only when reporting occurs.

Choice execution outcomes

Layer / File(s) Summary
Outcome contract and recorder
src/types/ChoiceOutcome.ts, src/choiceExecutor.ts, src/engine/choiceOutcomeRecorder.*
Outcomes support failure reasons and cancellation kinds, while ChoiceOutcomeRecorder records terminal success or failure once.
Template and capture outcome recording
src/engine/CaptureChoiceEngine.*, src/engine/TemplateChoiceEngine.*
Template and capture paths use centralized outcome recording and attach concrete failure messages at relevant execution branches.
Template failure cause tracking
src/engine/TemplateEngine.ts
Template file operations retain underlying failure messages for later engine outcome reporting.
CLI outcome forwarding
src/cli/registerQuickAddCliHandlers.*
CLI responses forward available failure reasons, use fallbacks when absent, and advertise abort capability for interactive sessions.

Interactive prompt abort protocol

Layer / File(s) Summary
Session abort and prompt cancellation
src/interactive/interactivePromptServer.ts
Sessions expose POST /abort, reject pending and future prompts, preserve terminal events, count interrupted prompts, and resolve cancelled info prompts.
Abort and cancellation validation
src/interactive/interactivePromptServer.test.ts, src/interactive/promptProvider.test.ts
Tests cover cancellation distinctions, abort routing, stale replies, pending counts, polling, and session cleanup.
Interactive client contract
docs/src/content/docs/docs/Advanced/CLI.md, src/interactive/promptProvider.ts
Documentation and comments describe abort, info cancellation, reply 409 responses, and capability negotiation.

Render fallback warnings

Layer / File(s) Summary
Shared fallback warning helper
src/gui/suggesters/utils.*
createRenderFallbackWarner preserves error diagnostics and emits one warning per fallback instance.
Suggester fallback integration
src/gui/GenericSuggester/*, src/gui/InputSuggester/*, src/gui/suggesters/choiceSuggester.ts
Suggester render failures use the shared warning helper and choice execution failures use cancellation-aware reporting while retaining fallback rendering.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Poem

A bunny saw errors hop in a queue,
So wrapped them once, and gave reasons too.
Prompts now abort when sessions say “stop,”
Render warnings hush after one tiny hop.
“Info” can close, while the run carries on—
Thump-thump, clearer outcomes greet the dawn!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and reflects the PR’s main theme: improving QuickAdd error reporting and cancellation handling.
Linked Issues check ✅ Passed The changes cover #1601#1605: deduped error reporting, better attribution, preserved failure reasons, safer render fallbacks, and remote abort/info parity.
Out of Scope Changes check ✅ Passed The diff stays focused on the linked issues; tests and docs support the same behavior changes rather than introducing unrelated features.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chhoumann/1601-error-reporting

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 767a15cbe5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/utils/errorUtils.ts Outdated
Comment thread src/engine/TemplateChoiceEngine.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/engine/TemplateChoiceEngine.ts`:
- Around line 217-230: Update appendToExistingFileWithTemplate’s canvas/base
guard to record the same specific failure it logs by calling
noteTemplateFileFailure with the constructed error and message before returning
null. Preserve the existing execution-scope failure marking and error log so
run() can use lastTemplateFileFailure via its existing fallback without emitting
a generic duplicate notice.

In `@src/interactive/interactivePromptServer.ts`:
- Around line 645-661: Drain the POST /abort request body before sending any
response to avoid leaving bytes on a keep-alive connection. In the /abort
handler near abortSession, consume the request using req.resume() with a small
byte limit or the existing capped readBody() mechanism, then preserve the
current interrupted status and response behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a5ee0c4-885d-4d97-acb4-a756a9b74040

📥 Commits

Reviewing files that changed from the base of the PR and between f9f158d and 767a15c.

📒 Files selected for processing (33)
  • docs/src/content/docs/docs/Advanced/CLI.md
  • src/ai/OpenAIRequest.test.ts
  • src/ai/OpenAIRequest.ts
  • src/choiceExecutor.ts
  • src/cli/registerQuickAddCliHandlers.test.ts
  • src/cli/registerQuickAddCliHandlers.ts
  • src/engine/CaptureChoiceEngine.notice.test.ts
  • src/engine/CaptureChoiceEngine.selection.test.ts
  • src/engine/CaptureChoiceEngine.ts
  • src/engine/MacroChoiceEngine.ts
  • src/engine/TemplateChoiceEngine.notice.test.ts
  • src/engine/TemplateChoiceEngine.ts
  • src/engine/TemplateEngine.ts
  • src/engine/choiceOutcomeRecorder.test.ts
  • src/engine/choiceOutcomeRecorder.ts
  • src/gui/GenericSuggester/genericSuggester.ts
  • src/gui/InputSuggester/inputSuggester.ts
  • src/gui/suggesters/choiceSuggester.ts
  • src/gui/suggesters/utils.test.ts
  • src/gui/suggesters/utils.ts
  • src/interactive/interactivePromptServer.test.ts
  • src/interactive/interactivePromptServer.ts
  • src/interactive/promptProvider.test.ts
  • src/interactive/promptProvider.ts
  • src/logger/logManager.ts
  • src/main.ts
  • src/quickAddApi.ts
  • src/types/ChoiceOutcome.ts
  • src/utils/errorUtils.test.ts
  • src/utils/errorUtils.ts
  • src/utils/unhandledRejectionReporter.test.ts
  • src/utils/unhandledRejectionReporter.ts
  • src/utils/userScript.ts

Comment thread src/engine/TemplateChoiceEngine.ts
Comment thread src/interactive/interactivePromptServer.ts
Two review findings from Codex on the PR, both real.

**Report-once must not be forever.** A long-lived user-script module that re-throws one
cached `Error` on every invocation was reported the first time and then silently
suppressed on every subsequent run - no notice, no console entry, a command that simply
does nothing. That is the exact failure the reporting seam exists to remove, so
suppression now expires after 10 seconds: the same window, for the same reason, as the
unhandled-rejection reporter's. One propagation unwinds in microseconds, so the stacked
notices in #1601 still collapse while separate runs stay separate.

**The canvas/base append guard lost its reason.** Appending a template to a `.canvas`
or `.base` file would splice raw text into structured JSON, so it is refused - with the
most actionable sentence any of these exits produces, since it names the fix ("Use the
Overwrite file-exists option instead"). It was the last report-and-return-null exit
still handing a CLI or interactive caller "Could not resolve file exists behavior".

Both are pinned by tests verified to fail against the unfixed source.

Refs #1601, #1603
/abort takes no body, but a client may still send one, and it answered without
reading - the only route that did. Unread bytes left on a keep-alive socket become
the next request's problem. Node drains an unconsumed request itself when the
response finishes, so this was not reachable in practice; reading it here makes the
route independent of that internal and symmetric with /reply.

Verified over a real keep-alive connection with one socket: `/abort` carrying a 2 KB
body answers `200 {"ok":true,"interrupted":1}`, the next request on the SAME socket
parses correctly, and the final poll still delivers the run's real outcome.

The router test's request stand-in is now async-iterable, which is what a real
IncomingMessage is - it 400'd against the drain otherwise, so the test was quietly
looser than production.

Refs #1605
@chhoumann
chhoumann merged commit 6691553 into master Jul 27, 2026
13 checks passed
@chhoumann
chhoumann deleted the chhoumann/1601-error-reporting branch July 27, 2026 18:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment