Skip to content
Merged
47 changes: 36 additions & 11 deletions docs/src/content/docs/docs/Advanced/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,30 +136,55 @@ the prompts opening in Obsidian.

```bash
obsidian vault=dev quickadd:interactive choice="Import from Readwise"
# -> {"ok":true,"host":"127.0.0.1","port":51789,"sessionId":"…","token":"…"}
# -> {"ok":true,"host":"127.0.0.1","port":51789,"sessionId":"…","token":"…","capabilities":["abort"]}
```

The command returns connection details immediately and runs the choice in the
background. Attach to the session and drive it:

- `GET http://127.0.0.1:<port>/poll?session=<id>&token=<token>` - long-polls for the next event: `{"kind":"prompt","requestId":…,"prompt":{…}}`, `{"kind":"done","result":…}`, `{"kind":"error","error":…}`, or a periodic `{"kind":"idle"}` keepalive (just poll again).
- `POST http://127.0.0.1:<port>/reply?session=<id>&token=<token>` with body `{"requestId":…,"value":…}` to answer, or `{"requestId":…,"cancelled":true}` to cancel (which aborts the run).
- `POST http://127.0.0.1:<port>/reply?session=<id>&token=<token>` with body `{"requestId":…,"value":…}` to answer, or `{"requestId":…,"cancelled":true}` to cancel (which ends the run - except on an `info` panel, see below).
- `POST http://127.0.0.1:<port>/abort?session=<id>&token=<token>` - end the run. Answers `{"ok":true,"interrupted":<n>}`, where `n` is how many pending prompts it rejected; `409` if the run had already finished (benign - poll for the terminal event); `404` for an unknown session or token, or for any method other than `POST`.

Prompt `type`s and the `value` you reply with: `suggester`/`input`/`date` →
string, `confirm` → boolean, `checkbox` → string array, `info` →
acknowledgement, `form` → an object mapping each field's `id` to its string
value (date fields use the `@date:ISO` format). The run's outcome arrives as the
`done`/`error` poll event.

### Cancelling

`{"cancelled":true}` is how you say *the user dismissed this prompt*. It aborts
the run exactly as pressing Escape on the in-app dialog does.
### Cancelling, and ending a run

`{"cancelled":true}` is how you say *the user dismissed this prompt*. It ends the
run exactly as pressing Escape on the in-app dialog does.

`info` is the exception, because the in-app dialog is: `GenericInfoDialog` resolves
on every close path and has no way to abort anything, so the same choice run in
Obsidian continues past the panel. Escape is the only gesture an info panel affords,
so cancelling one just closes it and the run carries on - matching the app.

To end a run deliberately, `POST /abort`. It rejects whatever the run is blocked on
and makes its next prompt fail too, so the run unwinds and delivers its **real**
outcome - usually `{"kind":"error","error":"Input cancelled by user"}`, but `done` if
it had nothing left to interrupt and simply finished. `/abort` never fabricates a
terminal event; keep polling until one arrives. The `interrupted` count tells you
whether it stopped anything.

:::caution[What `/abort` cannot reach]
`/abort` interrupts prompts that were routed **to you**. A run that is mid-work
between prompts keeps going, and a Template or Capture run still opens some prompts in
Obsidian itself - the "file already exists" chooser, the folder picker, the
capture-target picker - which do not travel over this bridge
([#1614](https://github.com/chhoumann/quickadd/issues/1614)). So `"interrupted":0`
means nothing was waiting on you, and the run may still succeed and commit its side
effects.
:::

Do not use it just to close an `info` panel. `info` is the one prompt that cannot
be cancelled in the app - the dialog has no reject path - so cancelling it
remotely aborts a run that would have continued. Send a plain reply instead. It
stays cancellable because it is a client's only explicit way to bail out mid-run.
:::note[Available in the next release]
`POST /abort` and the `info` behaviour above are new; `"capabilities":["abort"]` in
the handshake tells you a build has them. Before them, cancelling an `info` prompt
ended the run, and there was no explicit way to end one other than to stop polling
and wait out the ~75s disconnect watchdog.
:::

### When a reply is rejected

Expand All @@ -183,7 +208,7 @@ Every other prompt type accepts whatever you send, including an empty answer:
`""` and `[]` are things a user genuinely submits in the app (the Skip buttons,
optional fields), so they must stay legal here too.

A `409` means nothing was waiting on that `requestId`.
A `409` from `/reply` means nothing was waiting on that `requestId`.

Good to know:

Expand Down
12 changes: 11 additions & 1 deletion src/ai/OpenAIRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,17 @@ describe("OpenAIRequest", () => {
const [, result] = finishAIRequestLogEntryMock.mock.calls[0];
expect(result.status).toBe("error");
expect(result.errorMessage).toBe("Network down");
expect(logErrorMock).toHaveBeenCalledWith(networkError);
// The WRAPPER is what gets reported, not the bare provider error. Its message
// is a strict superset - it names the provider, and on a context-overflow it
// carries QuickAdd's remediation sentence - and `reportError` reports a
// failure once (#1601), so reporting the cause first would leave the user
// with the least informative half of the pair.
expect(logErrorMock).toHaveBeenCalledTimes(1);
const reported = logErrorMock.mock.calls[0][0] as Error;
expect(reported.message).toBe(
"Error while making request to OpenAI: Network down",
);
expect(reported.cause).toBe(networkError);
});

it("preserves the original error as the cause of the wrapped error", async () => {
Expand Down
21 changes: 16 additions & 5 deletions src/ai/OpenAIRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
finishAIRequestLogEntry,
} from "./AIAssistant";
import { preventCursorChange } from "./preventCursorChange";
import { reportError } from "../utils/errorUtils";
import type { AIProvider, Model } from "./Provider";
import { getProviderKind } from "./Provider";
import type { NormalizedChatRequest } from "./tools/NormalizedTools";
Expand Down Expand Up @@ -515,8 +516,6 @@ export function OpenAIRequest(
`[AI Request ${requestLogId}] Failed in ${durationMs}ms: ${errorMessage}`
);

log.logError(error as Error);

// Help users act on the most common failure: a prompt that overflows
// the model's context window. (ChunkedPrompt retries these automatically;
// the single-prompt path cannot, so we point the user at a remedy.)
Expand All @@ -525,10 +524,18 @@ export function OpenAIRequest(
? " The prompt likely exceeds the model's context window — shorten it, choose a model with a larger context, or use the chunked AI prompt API."
: "";

throw new Error(
// Report the WRAPPER, not the bare provider error, and report it before
// throwing so the failure is surfaced even if a caller swallows it. The
// wrapper's message is a strict superset - it names the provider and
// carries the guidance above - and since `reportError` reports a failure
// once (#1601), reporting the cause instead would leave the user with the
// least informative half of the pair.
const failure = new Error(
`Error while making request to ${modelProvider.name}: ${errorMessage}${guidance}`,
{ cause: error }
);
reportError(failure);
throw failure;
}
};
}
Expand Down Expand Up @@ -728,10 +735,14 @@ export async function chatRequest(
durationMs,
errorMessage,
});
log.logError(error as Error);
throw new Error(
// Report the wrapper, not the bare cause: its message names the provider, and
// `reportError` reports a failure once (#1601), so reporting the cause first
// would suppress the more informative message at every layer above.
const failure = new Error(
`Error while making request to ${modelProvider.name}: ${errorMessage}`,
{ cause: error },
);
reportError(failure);
throw failure;
}
}
5 changes: 4 additions & 1 deletion src/choiceExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,10 @@ export class ChoiceExecutor implements IChoiceExecutor {
return { status: "cancelled", cancelKind: "aborted", reason: error.message };
}
reportError(error, "Error executing choice from URI");
return { status: "error" };
return {
status: "error",
reason: error instanceof Error ? error.message : String(error),
};
} finally {
this.endExecutionContext();
}
Expand Down
124 changes: 124 additions & 0 deletions src/cli/registerQuickAddCliHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,22 @@ const {
ChoiceExecutorMock,
collectChoiceRequirementsMock,
getUnresolvedRequirementsMock,
interactiveServerMock,
} = vi.hoisted(() => ({
ChoiceExecutorMock: vi.fn(),
collectChoiceRequirementsMock: vi.fn(),
getUnresolvedRequirementsMock: vi.fn(),
// Stubbed so driving quickadd:interactive does not really bind a loopback port
// (and leave watchdog timers behind) just to read the terminal event.
interactiveServerMock: {
ensureStarted: vi.fn(),
createSession: vi.fn(),
finish: vi.fn(),
},
}));

vi.mock("../interactive/interactivePromptServer", () => ({
interactivePromptServer: interactiveServerMock,
}));

vi.mock("../choiceExecutor", () => ({
Expand Down Expand Up @@ -150,6 +162,15 @@ describe("registerQuickAddCliHandlers", () => {

collectChoiceRequirementsMock.mockResolvedValue([]);
getUnresolvedRequirementsMock.mockReturnValue([]);

interactiveServerMock.ensureStarted.mockReset();
interactiveServerMock.createSession.mockReset();
interactiveServerMock.finish.mockReset();
interactiveServerMock.ensureStarted.mockResolvedValue(51789);
interactiveServerMock.createSession.mockReturnValue({
id: "session-1",
token: "token-1",
});
});

it("registers run/list/check/preview handlers when CLI API is available", () => {
Expand Down Expand Up @@ -363,6 +384,109 @@ describe("registerQuickAddCliHandlers", () => {
expect(executors[0].execute).not.toHaveBeenCalled();
});

/**
* #1603. The engines report a failure with a desktop notice and return without
* recording anything, so the outcome carried no reason and the CLI substituted a
* fixed sentence - on the interactive path, to a client that is the whole reason
* nobody is looking at the desktop.
*/
it("forwards the engine's real message to an interactive client", async () => {
const { plugin, handlers } = createPlugin([templateChoice]);
registerQuickAddCliHandlers(plugin);
const interactive = handlers.find(
(handler) => handler.command === "quickadd:interactive",
);
ChoiceExecutorMock.mockImplementationOnce(function () {
const executor: IChoiceExecutor = {
execute: vi.fn().mockResolvedValue(undefined),
executeWithOutcome: vi.fn().mockResolvedValue({
status: "error",
reason: 'Template file not found at path "templates/x.md".',
}),
variables: new Map<string, unknown>(),
consumeAbortSignal: vi.fn().mockReturnValue(null),
};
executors.push(executor);
return executor;
});

const response = JSON.parse(
String(await interactive!.handler({ choice: "Template Choice" })),
);
expect(response.ok).toBe(true);
// The only feature-detection signal clients are told to use: it says both that
// POST /abort exists and that cancelling an `info` prompt no longer ends the
// run (#1605). An unknown path and an unauthed session answer the same 404
// shape, so without this a client could only detect the build by string-matching
// an error body.
expect(response.capabilities).toEqual(["abort"]);
// The run is fire-and-forget; let its microtasks settle.
await new Promise((resolve) => setTimeout(resolve, 0));

expect(interactiveServerMock.finish).toHaveBeenCalledWith("session-1", {
kind: "error",
error: 'Template file not found at path "templates/x.md".',
});
});

it("keeps the fixed sentence for an outcome that carries no reason", async () => {
const { plugin, handlers } = createPlugin([templateChoice]);
registerQuickAddCliHandlers(plugin);
const interactive = handlers.find(
(handler) => handler.command === "quickadd:interactive",
);
ChoiceExecutorMock.mockImplementationOnce(function () {
const executor: IChoiceExecutor = {
execute: vi.fn().mockResolvedValue(undefined),
executeWithOutcome: vi.fn().mockResolvedValue({ status: "error" }),
variables: new Map<string, unknown>(),
consumeAbortSignal: vi.fn().mockReturnValue(null),
};
executors.push(executor);
return executor;
});

await interactive!.handler({ choice: "Template Choice" });
await new Promise((resolve) => setTimeout(resolve, 0));

expect(interactiveServerMock.finish).toHaveBeenCalledWith("session-1", {
kind: "error",
error: "Choice execution failed; no file was created.",
});
});

it("forwards the engine's real message on the verified run-template path too", async () => {
const { plugin, handlers } = createPlugin([]);
withTemplateFile(plugin, "Templates/Daily.md");
registerQuickAddCliHandlers(plugin);
const run = handlers.find((h) => h.command === "quickadd:run-template");
ChoiceExecutorMock.mockImplementationOnce(function () {
const executor: IChoiceExecutor = {
execute: vi.fn().mockResolvedValue(undefined),
executeWithOutcome: vi.fn().mockResolvedValue({
status: "error",
reason: "Could not create file 'Daily.md'.",
}),
variables: new Map<string, unknown>(),
consumeAbortSignal: vi.fn().mockReturnValue(null),
};
executors.push(executor);
return executor;
});

const payload = JSON.parse(
String(
await run!.handler({
path: "Templates/Daily.md",
"value-value": "Daily",
}),
),
);

expect(payload.ok).toBe(false);
expect(payload.error).toBe("Could not create file 'Daily.md'.");
});

it("maps an error outcome to ok:false even when execute() would have resolved", async () => {
const { plugin, handlers } = createPlugin([]);
withTemplateFile(plugin, "Templates/Daily.md");
Expand Down
19 changes: 17 additions & 2 deletions src/cli/registerQuickAddCliHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,12 @@ async function runResolvedChoice(
return serialize({
ok: false,
command,
error: "Choice execution failed; no file was created.",
// The engine's own message, so a headless caller learns the cause
// (`Template file not found at path "…"`) instead of a fixed sentence
// while the actionable text goes to a desktop notice nobody is
// watching (#1603). The fallback is for an outcome that carries
// nothing at all - every engine failure exit records a reason.
error: outcome.reason || "Choice execution failed; no file was created.",
choice: describeChoice(choice),
durationMs,
});
Expand Down Expand Up @@ -791,7 +796,11 @@ async function interactiveHandler(
(outcome.cancelKind === "user"
? "Execution cancelled by user"
: "Execution aborted")
: "Choice execution failed; no file was created.",
: // The engine's real message. A remote client is the whole
// premise of this seam - nobody is at the desktop to read
// the notice that carries the same text (#1603).
outcome.reason ||
"Choice execution failed; no file was created.",
});
return;
}
Expand Down Expand Up @@ -825,6 +834,12 @@ async function interactiveHandler(
port,
sessionId,
token,
// Feature detection for the wire. `abort` says two things at once: this
// build serves `POST /abort`, and cancelling an `info` prompt closes the
// panel instead of ending the run (#1605). Without it a client could only
// tell the two behaviours apart by string-matching a 404 body, since an
// unknown path and an unauthed session answer the same shape.
capabilities: ["abort"],
});
} catch (error) {
return serialize({
Expand Down
9 changes: 8 additions & 1 deletion src/engine/CaptureChoiceEngine.notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,14 @@ describe("CaptureChoiceEngine append-link destination", () => {

expect(app.vault.adapter.exists).not.toHaveBeenCalled();
expect(app.vault.modify).not.toHaveBeenCalled();
expect(choiceExecutor.recordExecutionResult).not.toHaveBeenCalled();
// This exit used to record NOTHING, so `executeWithOutcome` produced a
// reason-less error and the CLI substituted its fixed sentence - #1603's exact
// symptom, reachable without any throw. It now carries the message the desktop
// notice carries.
expect(choiceExecutor.recordExecutionResult).toHaveBeenCalledWith({
status: "error",
reason: expect.stringContaining("Append link target file not found"),
});
expect(appendFileLinkToDestinationFileMock).not.toHaveBeenCalled();
expect(copyFileLinkToClipboardMock).not.toHaveBeenCalled();
});
Expand Down
3 changes: 3 additions & 0 deletions src/engine/CaptureChoiceEngine.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1248,8 +1248,11 @@ describe("CaptureChoiceEngine capture target resolution", () => {
await engine.run();

expect(app.fileManager.trashFile).toHaveBeenCalledWith(attachmentFile);
// The reason travels with the outcome so a CLI/interactive caller is told WHY,
// instead of the fixed "Choice execution failed; no file was created." (#1603).
expect(executor.recordExecutionResult).toHaveBeenCalledWith({
status: "error",
reason: expect.stringContaining("no active Markdown editor"),
});
});

Expand Down
Loading