From d41846771503e77711eaba394e8047b90f5c2bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Korm=C3=A1kur?= Date: Fri, 12 Jun 2026 23:58:32 +0000 Subject: [PATCH] fix(coding-agent): report local rpc prompt completion --- docs/rpc.md | 33 +++++- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/modes/rpc/rpc-mode.ts | 86 +++++++++++++- .../coding-agent/src/modes/rpc/rpc-types.ts | 8 +- .../coding-agent/src/modes/runtime-init.ts | 7 +- .../test/rpc-prompt-result.test.ts | 109 ++++++++++++++++++ 6 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/test/rpc-prompt-result.test.ts diff --git a/docs/rpc.md b/docs/rpc.md index b0ae74a57..750aa7198 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -45,8 +45,9 @@ There is no envelope beyond the object shape itself. 6. Host URI requests/cancellations (`host_uri_request`, `host_uri_cancel`) 7. Extension errors (`{ type: "extension_error", extensionPath, event, error }`) 8. Available-commands updates (`{ type: "available_commands_update", commands }`), emitted at startup and whenever command metadata changes -9. Subagent frames (`subagent_lifecycle`, `subagent_progress`, `subagent_event`), gated by `set_subagent_subscription` -10. Builtin slash-command side channels (`command_output`, `session_info_update`, `config_update`) +9. Prompt lifecycle hints (`{ type: "prompt_result", id?, agentInvoked }`) for scheduled prompts that later resolve without invoking the agent +10. Subagent frames (`subagent_lifecycle`, `subagent_progress`, `subagent_event`), gated by `set_subagent_subscription` +11. Builtin slash-command side channels (`command_output`, `session_info_update`, `config_update`) ### Inbound frame categories (stdin) @@ -67,6 +68,7 @@ Important edge behavior from runtime: - Unknown command responses are emitted with `id: undefined` (even if the request had an `id`). - Parse/handler exceptions in the input loop emit `command: "parse"` with `id: undefined`. - `prompt` and `abort_and_prompt` return immediate success, then may emit a later error response with the **same** id if async prompt scheduling fails. +- `prompt` success responses may include `data.agentInvoked`. `false` means the prompt completed locally without an agent turn; `true` means an agent turn was scheduled; omitted means the host must use session events for completion. ## Command Schema (canonical) @@ -153,6 +155,30 @@ All command results use `RpcResponse`: Data payloads are command-specific and defined in `rpc-types.ts`. +### `prompt` payload + +`prompt` is acknowledged after the command is accepted, not after a model turn finishes: + +```json +{ + "id": "req_1", + "type": "response", + "command": "prompt", + "success": true, + "data": { "agentInvoked": false } +} +``` + +`data.agentInvoked: false` is a completion signal for local-only prompts, including slash commands that produce output without starting an agent turn. `data.agentInvoked: true` means an agent turn was scheduled and completion follows the normal event stream. Older runtimes may omit `data`; hosts should then rely on `agent_end`, custom message completion, or `prompt_result`. + +`prompt_result` is emitted when a prompt was accepted immediately but later resolves as local-only: + +```json +{ "type": "prompt_result", "id": "req_1", "agentInvoked": false } +``` + +Local-only slash commands may emit `command_output` frames before completing via `data.agentInvoked: false` or a later `prompt_result`. They do not emit `agent_end`. + ### `get_state` payload ```json @@ -344,7 +370,8 @@ This is the most important operational behavior. That means: - command acceptance != run completion -- final completion is observed via `agent_end` +- agent turns complete via `agent_end` +- local-only prompts complete via `data.agentInvoked: false` on the response or via a later `prompt_result` ### While streaming diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 55fe1bc60..8c1721b70 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -165,6 +165,7 @@ - Mnemopi `per-project` / `per-project-tagged` bank derivation is now stable for one cwd, ignoring the surrounding git layout. Previously the bank id was hashed from `git.repo.resolveSync(cwd)?.repoRoot ?? path.resolve(cwd)`, so adding or removing a `.git` anywhere above the working directory silently repointed the same conversation to a new bank and stranded its memories (e.g. `/home/x/projects/repo` flipping between `projects-…` and `repo-…`). The derivation in `packages/coding-agent/src/mnemopi/config.ts` now hashes `path.resolve(cwd)` directly, and session startup widens the recall set with any sibling bank under `/banks/` whose `working_memory` rows already carry the active cwd in `metadata_json.$.cwd`, so memories stranded by the old, less-stable derivation become visible again on the next session without manual migration ([#2412](https://github.com/can1357/oh-my-pi/issues/2412)). - Fixed model switching (Ctrl+P role cycling and the alt+p / `/switch` / `/models` selector) intermittently freezing the UI for several seconds. `AgentSession.setModel`/`setModelTemporary` ran an eager `await modelRegistry.getApiKey(model)` purely as an existence pre-flight and discarded the value — but `getApiKey` does real work: it synchronously executes command-backed key programs (`apiKey: "!cmd"`, `execSync` with a 10s timeout, blocking the event loop) and refreshes OAuth tokens over the network when one crosses the expiry window (the "fine for a few switches, then a multi-second stall" symptom). Switching now uses the synchronous, side-effect-free `ModelRegistry.hasConfiguredAuth` check; the concrete key (command execution + OAuth refresh) is still resolved lazily per request via the existing resolver, so an unconfigured provider still fails fast with `No API key` while a healthy switch never touches the network or spawns a subprocess. `hasConfiguredAuth` no longer runs the command program or refreshes tokens either, matching its documented "probe without resolving an API key" contract. - Fixed session resume (`pi -c` / `--continue` / `--session`) hanging for ~10s at startup — surfaced by the watchdog as `Still starting … phase: createAgentSession > restoreSessionModel` — when an OAuth token needed refreshing or the auth broker (`OMP_AUTH_BROKER_URL`) was unreachable. Picking which saved model to restore is a pure *selection* that only needs to know whether auth is configured, but `restoreSessionModel` probed each candidate with the async `getApiKey`, which refreshes OAuth tokens over the network, executes command-backed key programs, and issues auth-broker requests — so a slow or unreachable endpoint stalled resume for the full refresh timeout per candidate. Startup model selection now uses the synchronous, side-effect-free `ModelRegistry.hasConfiguredAuth` probe (the same fix already applied to interactive model switching); the concrete key is still resolved lazily on the first request via the resolver. +- Added RPC prompt lifecycle hints so hosts can distinguish scheduled agent turns from local-only slash commands via `data.agentInvoked` and `prompt_result`. ## [15.12.3] - 2026-06-12 diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 425f926a5..a718c0ab1 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -100,6 +100,67 @@ export async function tryRunRpcSkillCommand(session: RpcSkillCommandSession, tex }); return true; } + +export function reportLocalOnlyPromptResult(input: { + id: string | undefined; + prompt: Promise; + output: (obj: object) => void; + onError: (error: Error) => void; + hasExtensionUserMessageTask?: () => boolean; +}): void { + void input.prompt + .then(agentInvoked => { + if (!agentInvoked && !input.hasExtensionUserMessageTask?.()) { + input.output({ type: "prompt_result", id: input.id, agentInvoked: false }); + } + }) + .catch(error => { + input.onError(error instanceof Error ? error : new Error(String(error))); + }); +} + +type RpcExtensionUserMessageScope = { + hasUserMessageTask: boolean; +}; + +/** + * Tracks extension-originated user messages while an RPC prompt is executing. + * A slash command can resolve the outer prompt as local-only while also + * scheduling agent work through pi.sendUserMessage(); that prompt must not + * report agentInvoked:false to the host. + */ +export class RpcExtensionUserMessageTracker { + #activePromptScopes = new Set(); + + track(task: Promise): void { + void task; + for (const scope of this.#activePromptScopes) { + scope.hasUserMessageTask = true; + } + } + + watchPrompt(startPrompt: () => Promise): { + prompt: Promise; + hasUserMessageTask: () => boolean; + } { + const scope: RpcExtensionUserMessageScope = { hasUserMessageTask: false }; + this.#activePromptScopes.add(scope); + let prompt: Promise; + try { + prompt = startPrompt(); + } catch (error) { + this.#activePromptScopes.delete(scope); + throw error; + } + return { + prompt: prompt.finally(() => { + this.#activePromptScopes.delete(scope); + }), + hasUserMessageTask: () => scope.hasUserMessageTask, + }; + } +} + export type RpcSubagentResetRegistry = Pick; export async function handleRpcSessionChange( @@ -277,6 +338,8 @@ export async function runRpcMode( return { id, type: "response", command, success: false, error: message }; }; + const extensionUserMessageTracker = new RpcExtensionUserMessageTracker(); + const pendingExtensionRequests = new Map(); const hostToolBridge = new RpcHostToolBridge(output); const hostUriBridge = new RpcHostUriBridge(output); @@ -533,6 +596,9 @@ export async function runRpcMode( onShutdown: () => { shutdownState.requested = true; }, + trackUserMessageTask: task => { + extensionUserMessageTracker.track(task); + }, uiContext: rpcUiContext, }); @@ -570,7 +636,7 @@ export async function runRpcMode( case "prompt": { if (await tryRunRpcSkillCommand(session, command.message)) { - return success(id, "prompt"); + return success(id, "prompt", { agentInvoked: false }); } const builtinResult = await executeAcpBuiltinSlashCommand(command.message, { session, @@ -592,19 +658,27 @@ export async function runRpcMode( session .prompt(builtinResult.prompt, { images: command.images }) .catch(e => output(error(id, "prompt", e.message))); + return success(id, "prompt", { agentInvoked: true }); } - return success(id, "prompt"); + return success(id, "prompt", { agentInvoked: false }); } // Don't await - events will stream // Extension commands are executed immediately, file prompt templates are expanded // If streaming and streamingBehavior specified, queues via steer/followUp - session - .prompt(command.message, { + const trackedPrompt = extensionUserMessageTracker.watchPrompt(() => + session.prompt(command.message, { images: command.images, streamingBehavior: command.streamingBehavior, - }) - .catch(e => output(error(id, "prompt", e.message))); + }), + ); + reportLocalOnlyPromptResult({ + id, + prompt: trackedPrompt.prompt, + output, + onError: promptError => output(error(id, "prompt", promptError.message)), + hasExtensionUserMessageTask: trackedPrompt.hasUserMessageTask, + }); return success(id, "prompt"); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 431490039..51ea03252 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -126,6 +126,12 @@ export interface RpcAvailableCommandsUpdateFrame { commands: RpcAvailableSlashCommand[]; } +export interface RpcPromptResultFrame { + type: "prompt_result"; + id?: string; + agentInvoked: boolean; +} + export interface RpcHandoffResult { savedPath?: string; } @@ -163,7 +169,7 @@ export interface RpcSubagentMessagesResult { // Success responses with data export type RpcResponse = // Prompting (async - events follow) - | { id?: string; type: "response"; command: "prompt"; success: true } + | { id?: string; type: "response"; command: "prompt"; success: true; data?: { agentInvoked: boolean } } | { id?: string; type: "response"; command: "steer"; success: true } | { id?: string; type: "response"; command: "follow_up"; success: true } | { id?: string; type: "response"; command: "abort"; success: true } diff --git a/packages/coding-agent/src/modes/runtime-init.ts b/packages/coding-agent/src/modes/runtime-init.ts index a7164496c..a1c270d22 100644 --- a/packages/coding-agent/src/modes/runtime-init.ts +++ b/packages/coding-agent/src/modes/runtime-init.ts @@ -23,6 +23,8 @@ export interface InitializeExtensionsOptions { onShutdown?: () => void; /** Optional UI context (rpc supplies one; print runs headless). */ uiContext?: ExtensionUIContext; + /** Optional lifecycle hook for extension-originated user-message tasks. */ + trackUserMessageTask?: (task: Promise) => void; } /** @@ -35,7 +37,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi const runner = session.extensionRunner; if (!runner) return; - const { reportSendError, reportRuntimeError, onShutdown, uiContext } = options; + const { reportSendError, reportRuntimeError, onShutdown, uiContext, trackUserMessageTask } = options; const shutdown = onShutdown ?? (() => {}); runner.initialize( @@ -47,9 +49,10 @@ export async function initializeExtensions(session: AgentSession, options: Initi }); }, sendUserMessage: (content, sendOptions) => { - session.sendUserMessage(content, sendOptions).catch(e => { + const task = session.sendUserMessage(content, sendOptions).catch(e => { reportSendError("extension_send_user", e instanceof Error ? e : new Error(String(e))); }); + trackUserMessageTask?.(task); }, appendEntry: (customType, data) => { session.sessionManager.appendCustomEntry(customType, data); diff --git a/packages/coding-agent/test/rpc-prompt-result.test.ts b/packages/coding-agent/test/rpc-prompt-result.test.ts new file mode 100644 index 000000000..b7aa59942 --- /dev/null +++ b/packages/coding-agent/test/rpc-prompt-result.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { + RpcExtensionUserMessageTracker, + reportLocalOnlyPromptResult, +} from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-mode"; + +async function flushPromptResult(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("reportLocalOnlyPromptResult", () => { + test("emits prompt_result when prompt resolves without invoking the agent or extension user message", async () => { + const output: object[] = []; + const extensionUserMessages = new RpcExtensionUserMessageTracker(); + const trackedPrompt = extensionUserMessages.watchPrompt(() => Promise.resolve(false)); + + reportLocalOnlyPromptResult({ + id: "req_1", + prompt: trackedPrompt.prompt, + output: frame => output.push(frame), + onError: error => { + throw error; + }, + hasExtensionUserMessageTask: trackedPrompt.hasUserMessageTask, + }); + await flushPromptResult(); + + expect(output).toEqual([{ type: "prompt_result", id: "req_1", agentInvoked: false }]); + }); + + test("does not emit false prompt_result when an extension command schedules a user message", async () => { + const output: object[] = []; + const extensionUserMessages = new RpcExtensionUserMessageTracker(); + const trackedPrompt = extensionUserMessages.watchPrompt(() => { + extensionUserMessages.track(Promise.resolve()); + return Promise.resolve(false); + }); + + reportLocalOnlyPromptResult({ + id: "req_1", + prompt: trackedPrompt.prompt, + output: frame => output.push(frame), + onError: error => { + throw error; + }, + hasExtensionUserMessageTask: trackedPrompt.hasUserMessageTask, + }); + await flushPromptResult(); + + expect(output).toEqual([]); + }); + + test("ignores extension user messages scheduled before the watched prompt", async () => { + const output: object[] = []; + const extensionUserMessages = new RpcExtensionUserMessageTracker(); + extensionUserMessages.track(Promise.resolve()); + const trackedPrompt = extensionUserMessages.watchPrompt(() => Promise.resolve(false)); + + reportLocalOnlyPromptResult({ + id: "req_1", + prompt: trackedPrompt.prompt, + output: frame => output.push(frame), + onError: error => { + throw error; + }, + hasExtensionUserMessageTask: trackedPrompt.hasUserMessageTask, + }); + await flushPromptResult(); + + expect(output).toEqual([{ type: "prompt_result", id: "req_1", agentInvoked: false }]); + }); + + test("does not emit when prompt invokes the agent", async () => { + const output: object[] = []; + + reportLocalOnlyPromptResult({ + id: "req_1", + prompt: Promise.resolve(true), + output: frame => output.push(frame), + onError: error => { + throw error; + }, + }); + await flushPromptResult(); + + expect(output).toEqual([]); + }); + + test("reports prompt rejection without emitting output", async () => { + const output: object[] = []; + const thrown = new Error("boom"); + let reported: Error | undefined; + + reportLocalOnlyPromptResult({ + id: "req_1", + prompt: Promise.reject(thrown), + output: frame => output.push(frame), + onError: error => { + reported = error; + }, + }); + await flushPromptResult(); + + expect(reported).toBe(thrown); + expect(output).toEqual([]); + }); +});