fix(coding-agent): emit rpc prompt response after preflight closes #3049
This commit is contained in:
@@ -180,6 +180,8 @@ export interface PromptOptions {
|
||||
streamingBehavior?: "steer" | "followUp";
|
||||
/** Source of input for extension input event handlers. Defaults to "interactive". */
|
||||
source?: InputSource;
|
||||
/** Internal hook used by RPC mode to observe prompt preflight acceptance or rejection. */
|
||||
preflightResult?: (success: boolean) => void;
|
||||
}
|
||||
|
||||
/** Result from cycleModel() */
|
||||
@@ -928,139 +930,154 @@ export class AgentSession {
|
||||
*/
|
||||
async prompt(text: string, options?: PromptOptions): Promise<void> {
|
||||
const expandPromptTemplates = options?.expandPromptTemplates ?? true;
|
||||
const preflightResult = options?.preflightResult;
|
||||
let messages: AgentMessage[] | undefined;
|
||||
|
||||
// Handle extension commands first (execute immediately, even during streaming)
|
||||
// Extension commands manage their own LLM interaction via pi.sendMessage()
|
||||
if (expandPromptTemplates && text.startsWith("/")) {
|
||||
const handled = await this._tryExecuteExtensionCommand(text);
|
||||
if (handled) {
|
||||
// Extension command executed, no prompt to send
|
||||
try {
|
||||
// Handle extension commands first (execute immediately, even during streaming)
|
||||
// Extension commands manage their own LLM interaction via pi.sendMessage()
|
||||
if (expandPromptTemplates && text.startsWith("/")) {
|
||||
const handled = await this._tryExecuteExtensionCommand(text);
|
||||
if (handled) {
|
||||
// Extension command executed, no prompt to send
|
||||
preflightResult?.(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit input event for extension interception (before skill/template expansion)
|
||||
let currentText = text;
|
||||
let currentImages = options?.images;
|
||||
if (this._extensionRunner?.hasHandlers("input")) {
|
||||
const inputResult = await this._extensionRunner.emitInput(
|
||||
currentText,
|
||||
currentImages,
|
||||
options?.source ?? "interactive",
|
||||
);
|
||||
if (inputResult.action === "handled") {
|
||||
preflightResult?.(true);
|
||||
return;
|
||||
}
|
||||
if (inputResult.action === "transform") {
|
||||
currentText = inputResult.text;
|
||||
currentImages = inputResult.images ?? currentImages;
|
||||
}
|
||||
}
|
||||
|
||||
// Expand skill commands (/skill:name args) and prompt templates (/template args)
|
||||
let expandedText = currentText;
|
||||
if (expandPromptTemplates) {
|
||||
expandedText = this._expandSkillCommand(expandedText);
|
||||
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
|
||||
}
|
||||
|
||||
// If streaming, queue via steer() or followUp() based on option
|
||||
if (this.isStreaming) {
|
||||
if (!options?.streamingBehavior) {
|
||||
throw new Error(
|
||||
"Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
|
||||
);
|
||||
}
|
||||
if (options.streamingBehavior === "followUp") {
|
||||
await this._queueFollowUp(expandedText, currentImages);
|
||||
} else {
|
||||
await this._queueSteer(expandedText, currentImages);
|
||||
}
|
||||
preflightResult?.(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit input event for extension interception (before skill/template expansion)
|
||||
let currentText = text;
|
||||
let currentImages = options?.images;
|
||||
if (this._extensionRunner?.hasHandlers("input")) {
|
||||
const inputResult = await this._extensionRunner.emitInput(
|
||||
currentText,
|
||||
currentImages,
|
||||
options?.source ?? "interactive",
|
||||
);
|
||||
if (inputResult.action === "handled") {
|
||||
return;
|
||||
}
|
||||
if (inputResult.action === "transform") {
|
||||
currentText = inputResult.text;
|
||||
currentImages = inputResult.images ?? currentImages;
|
||||
}
|
||||
}
|
||||
// Flush any pending bash messages before the new prompt
|
||||
this._flushPendingBashMessages();
|
||||
|
||||
// Expand skill commands (/skill:name args) and prompt templates (/template args)
|
||||
let expandedText = currentText;
|
||||
if (expandPromptTemplates) {
|
||||
expandedText = this._expandSkillCommand(expandedText);
|
||||
expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
|
||||
}
|
||||
|
||||
// If streaming, queue via steer() or followUp() based on option
|
||||
if (this.isStreaming) {
|
||||
if (!options?.streamingBehavior) {
|
||||
// Validate model
|
||||
if (!this.model) {
|
||||
throw new Error(
|
||||
"Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
|
||||
"No model selected.\n\n" +
|
||||
`Use /login or set an API key environment variable. See ${join(getDocsPath(), "providers.md")}\n\n` +
|
||||
"Then use /model to select a model.",
|
||||
);
|
||||
}
|
||||
if (options.streamingBehavior === "followUp") {
|
||||
await this._queueFollowUp(expandedText, currentImages);
|
||||
} else {
|
||||
await this._queueSteer(expandedText, currentImages);
|
||||
|
||||
if (!this._modelRegistry.hasConfiguredAuth(this.model)) {
|
||||
const isOAuth = this._modelRegistry.isUsingOAuth(this.model);
|
||||
if (isOAuth) {
|
||||
throw new Error(
|
||||
`Authentication failed for "${this.model.provider}". ` +
|
||||
`Credentials may have expired or network is unavailable. ` +
|
||||
`Run '/login ${this.model.provider}' to re-authenticate.`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`No API key found for ${this.model.provider}.\n\n` +
|
||||
`Use /login or set an API key environment variable. See ${join(getDocsPath(), "providers.md")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we need to compact before sending (catches aborted responses)
|
||||
const lastAssistant = this._findLastAssistantMessage();
|
||||
if (lastAssistant) {
|
||||
await this._checkCompaction(lastAssistant, false);
|
||||
}
|
||||
|
||||
// Build messages array (custom message if any, then user message)
|
||||
messages = [];
|
||||
|
||||
// Add user message
|
||||
const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
|
||||
if (currentImages) {
|
||||
userContent.push(...currentImages);
|
||||
}
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: userContent,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Inject any pending "nextTurn" messages as context alongside the user message
|
||||
for (const msg of this._pendingNextTurnMessages) {
|
||||
messages.push(msg);
|
||||
}
|
||||
this._pendingNextTurnMessages = [];
|
||||
|
||||
// Emit before_agent_start extension event
|
||||
if (this._extensionRunner) {
|
||||
const result = await this._extensionRunner.emitBeforeAgentStart(
|
||||
expandedText,
|
||||
currentImages,
|
||||
this._baseSystemPrompt,
|
||||
);
|
||||
// Add all custom messages from extensions
|
||||
if (result?.messages) {
|
||||
for (const msg of result.messages) {
|
||||
messages.push({
|
||||
role: "custom",
|
||||
customType: msg.customType,
|
||||
content: msg.content,
|
||||
display: msg.display,
|
||||
details: msg.details,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Apply extension-modified system prompt, or reset to base
|
||||
if (result?.systemPrompt) {
|
||||
this.agent.state.systemPrompt = result.systemPrompt;
|
||||
} else {
|
||||
// Ensure we're using the base prompt (in case previous turn had modifications)
|
||||
this.agent.state.systemPrompt = this._baseSystemPrompt;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
preflightResult?.(false);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!messages) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush any pending bash messages before the new prompt
|
||||
this._flushPendingBashMessages();
|
||||
|
||||
// Validate model
|
||||
if (!this.model) {
|
||||
throw new Error(
|
||||
"No model selected.\n\n" +
|
||||
`Use /login or set an API key environment variable. See ${join(getDocsPath(), "providers.md")}\n\n` +
|
||||
"Then use /model to select a model.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!this._modelRegistry.hasConfiguredAuth(this.model)) {
|
||||
const isOAuth = this._modelRegistry.isUsingOAuth(this.model);
|
||||
if (isOAuth) {
|
||||
throw new Error(
|
||||
`Authentication failed for "${this.model.provider}". ` +
|
||||
`Credentials may have expired or network is unavailable. ` +
|
||||
`Run '/login ${this.model.provider}' to re-authenticate.`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`No API key found for ${this.model.provider}.\n\n` +
|
||||
`Use /login or set an API key environment variable. See ${join(getDocsPath(), "providers.md")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we need to compact before sending (catches aborted responses)
|
||||
const lastAssistant = this._findLastAssistantMessage();
|
||||
if (lastAssistant) {
|
||||
await this._checkCompaction(lastAssistant, false);
|
||||
}
|
||||
|
||||
// Build messages array (custom message if any, then user message)
|
||||
const messages: AgentMessage[] = [];
|
||||
|
||||
// Add user message
|
||||
const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
|
||||
if (currentImages) {
|
||||
userContent.push(...currentImages);
|
||||
}
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: userContent,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Inject any pending "nextTurn" messages as context alongside the user message
|
||||
for (const msg of this._pendingNextTurnMessages) {
|
||||
messages.push(msg);
|
||||
}
|
||||
this._pendingNextTurnMessages = [];
|
||||
|
||||
// Emit before_agent_start extension event
|
||||
if (this._extensionRunner) {
|
||||
const result = await this._extensionRunner.emitBeforeAgentStart(
|
||||
expandedText,
|
||||
currentImages,
|
||||
this._baseSystemPrompt,
|
||||
);
|
||||
// Add all custom messages from extensions
|
||||
if (result?.messages) {
|
||||
for (const msg of result.messages) {
|
||||
messages.push({
|
||||
role: "custom",
|
||||
customType: msg.customType,
|
||||
content: msg.content,
|
||||
display: msg.display,
|
||||
details: msg.details,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Apply extension-modified system prompt, or reset to base
|
||||
if (result?.systemPrompt) {
|
||||
this.agent.state.systemPrompt = result.systemPrompt;
|
||||
} else {
|
||||
// Ensure we're using the base prompt (in case previous turn had modifications)
|
||||
this.agent.state.systemPrompt = this._baseSystemPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
preflightResult?.(true);
|
||||
await this.agent.prompt(messages);
|
||||
await this.waitForRetry();
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
||||
registerSignalHandlers();
|
||||
|
||||
// Handle a single command
|
||||
const handleCommand = async (command: RpcCommand): Promise<RpcResponse> => {
|
||||
const handleCommand = async (command: RpcCommand): Promise<RpcResponse | undefined> => {
|
||||
const id = command.id;
|
||||
|
||||
switch (command.type) {
|
||||
@@ -366,17 +366,27 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
||||
// =================================================================
|
||||
|
||||
case "prompt": {
|
||||
// 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
|
||||
// Start prompt handling immediately, but emit the authoritative response only after
|
||||
// prompt preflight succeeds. Queued and immediately handled prompts also count as success.
|
||||
let preflightSucceeded = false;
|
||||
void session
|
||||
.prompt(command.message, {
|
||||
images: command.images,
|
||||
streamingBehavior: command.streamingBehavior,
|
||||
source: "rpc",
|
||||
preflightResult: (didSucceed) => {
|
||||
if (didSucceed) {
|
||||
preflightSucceeded = true;
|
||||
output(success(id, "prompt"));
|
||||
}
|
||||
},
|
||||
})
|
||||
.catch((e) => output(error(id, "prompt", e.message)));
|
||||
return success(id, "prompt");
|
||||
.catch((e) => {
|
||||
if (!preflightSucceeded) {
|
||||
output(error(id, "prompt", e.message));
|
||||
}
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
case "steer": {
|
||||
@@ -686,7 +696,9 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
||||
const command = parsed as RpcCommand;
|
||||
try {
|
||||
const response = await handleCommand(command);
|
||||
output(response);
|
||||
if (response) {
|
||||
output(response);
|
||||
}
|
||||
await checkShutdownRequested();
|
||||
} catch (commandError: unknown) {
|
||||
output(
|
||||
|
||||
Reference in New Issue
Block a user