fix(coding-agent): report local rpc prompt completion
This commit is contained in:
+30
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user