fix(coding-agent): report local rpc prompt completion

This commit is contained in:
Kormákur
2026-06-12 23:58:32 +00:00
committed by can1357
parent 9a5e44df68
commit d418467715
6 changed files with 232 additions and 12 deletions
+30 -3
View File
@@ -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
+1
View File
@@ -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 `<dbDir>/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
@@ -100,6 +100,67 @@ export async function tryRunRpcSkillCommand(session: RpcSkillCommandSession, tex
});
return true;
}
export function reportLocalOnlyPromptResult(input: {
id: string | undefined;
prompt: Promise<boolean>;
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<RpcExtensionUserMessageScope>();
track(task: Promise<void>): void {
void task;
for (const scope of this.#activePromptScopes) {
scope.hasUserMessageTask = true;
}
}
watchPrompt<T>(startPrompt: () => Promise<T>): {
prompt: Promise<T>;
hasUserMessageTask: () => boolean;
} {
const scope: RpcExtensionUserMessageScope = { hasUserMessageTask: false };
this.#activePromptScopes.add(scope);
let prompt: Promise<T>;
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<RpcSubagentRegistry, "clear">;
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<string, PendingExtensionRequest>();
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");
}
@@ -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 }
@@ -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>) => 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);
@@ -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<void> {
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([]);
});
});