feat: add ephemeral /btw side-question panel (#399)
* feat: add ephemeral /btw side-question panel * fix: preserve /btw live context and payload hooks * fix: send /btw question through session pipeline
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { type Component, Container, Markdown, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { replaceTabs } from "../../tools/render-utils";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
|
||||
type BtwPanelState = "running" | "complete" | "aborted" | "error";
|
||||
|
||||
interface BtwPanelComponentOptions {
|
||||
question: string;
|
||||
tui: TUI;
|
||||
}
|
||||
|
||||
export class BtwPanelComponent extends Container {
|
||||
#question: string;
|
||||
#tui: TUI;
|
||||
#state: BtwPanelState = "running";
|
||||
#answer = "";
|
||||
#errorMessage: string | undefined;
|
||||
#closed = false;
|
||||
|
||||
constructor(options: BtwPanelComponentOptions) {
|
||||
super();
|
||||
this.#question = options.question;
|
||||
this.#tui = options.tui;
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
appendText(delta: string): void {
|
||||
if (!delta || this.#closed) return;
|
||||
this.#answer += delta;
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
setAnswer(text: string): void {
|
||||
if (this.#closed) return;
|
||||
this.#answer = text;
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
markComplete(): void {
|
||||
if (this.#closed) return;
|
||||
this.#state = "complete";
|
||||
this.#errorMessage = undefined;
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
markAborted(): void {
|
||||
if (this.#closed) return;
|
||||
this.#state = "aborted";
|
||||
this.#errorMessage = undefined;
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
markError(message: string): void {
|
||||
if (this.#closed) return;
|
||||
this.#state = "error";
|
||||
this.#errorMessage = message;
|
||||
this.#rebuild();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#closed = true;
|
||||
}
|
||||
|
||||
#rebuild(): void {
|
||||
this.clear();
|
||||
this.addChild(new DynamicBorder(str => theme.fg("dim", str)));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.fg("accent", replaceTabs(this.#question)), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.#contentComponent());
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(this.#footerLine(), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder(str => theme.fg("dim", str)));
|
||||
this.#tui.requestRender();
|
||||
}
|
||||
|
||||
#footerLine(): string {
|
||||
switch (this.#state) {
|
||||
case "running":
|
||||
return theme.fg("muted", "Esc cancel /btw");
|
||||
case "complete":
|
||||
return theme.fg("muted", "Esc dismiss");
|
||||
case "aborted":
|
||||
return theme.fg("warning", `${theme.status.warning} Cancelled · Esc dismiss`);
|
||||
case "error":
|
||||
return theme.fg("error", `${theme.status.error} Error · Esc dismiss`);
|
||||
}
|
||||
}
|
||||
|
||||
#contentComponent(): Component {
|
||||
if (this.#state === "error") {
|
||||
return new Text(theme.fg("error", replaceTabs(this.#errorMessage ?? "Unknown error")), 1, 0);
|
||||
}
|
||||
const text = replaceTabs(this.#answer).trim();
|
||||
if (!text) {
|
||||
const waiting =
|
||||
this.#state === "running" ? `${theme.status.pending} Waiting for response…` : "No text returned.";
|
||||
return new Text(theme.fg("dim", waiting), 1, 0);
|
||||
}
|
||||
return new Markdown(text, 1, 0, getMarkdownTheme());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import { type AssistantMessage, type Context, streamSimple } from "@oh-my-pi/pi-ai";
|
||||
import { renderPromptTemplate } from "../../config/prompt-templates";
|
||||
import btwUserPrompt from "../../prompts/system/btw-user.md" with { type: "text" };
|
||||
import { toReasoningEffort } from "../../thinking";
|
||||
import { BtwPanelComponent } from "../components/btw-panel";
|
||||
import type { InteractiveModeContext } from "../types";
|
||||
|
||||
interface BtwRequest {
|
||||
component: BtwPanelComponent;
|
||||
abortController: AbortController;
|
||||
question: string;
|
||||
}
|
||||
|
||||
export class BtwController {
|
||||
#activeRequest: BtwRequest | undefined;
|
||||
readonly #streamFn: typeof streamSimple;
|
||||
|
||||
constructor(
|
||||
private readonly ctx: InteractiveModeContext,
|
||||
options?: { streamFn?: typeof streamSimple },
|
||||
) {
|
||||
this.#streamFn = options?.streamFn ?? streamSimple;
|
||||
}
|
||||
|
||||
hasActiveRequest(): boolean {
|
||||
return this.#activeRequest !== undefined;
|
||||
}
|
||||
|
||||
handleEscape(): boolean {
|
||||
if (!this.#activeRequest) return false;
|
||||
this.#closeActiveRequest({ abort: this.#activeRequest.abortController.signal.aborted === false });
|
||||
return true;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.#closeActiveRequest({ abort: true });
|
||||
}
|
||||
|
||||
async start(question: string): Promise<void> {
|
||||
const trimmedQuestion = question.trim();
|
||||
if (!trimmedQuestion) {
|
||||
this.ctx.showStatus("Usage: /btw <question>");
|
||||
return;
|
||||
}
|
||||
|
||||
const model = this.ctx.session.model;
|
||||
if (!model) {
|
||||
this.ctx.showError("No active model available for /btw.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.#closeActiveRequest({ abort: true });
|
||||
|
||||
const request: BtwRequest = {
|
||||
component: new BtwPanelComponent({ question: trimmedQuestion, tui: this.ctx.ui }),
|
||||
abortController: new AbortController(),
|
||||
question: trimmedQuestion,
|
||||
};
|
||||
this.ctx.btwContainer.clear();
|
||||
this.ctx.btwContainer.addChild(request.component);
|
||||
this.ctx.ui.requestRender();
|
||||
this.#activeRequest = request;
|
||||
void this.#runRequest(request, model);
|
||||
}
|
||||
|
||||
async #runRequest(
|
||||
request: BtwRequest,
|
||||
model: NonNullable<InteractiveModeContext["session"]["model"]>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const apiKey = await this.ctx.session.modelRegistry.getApiKey(model, this.ctx.session.sessionId);
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
|
||||
const llmMessages = await this.ctx.session.convertMessagesToLlm(
|
||||
[...this.#buildMessageSnapshot(), this.#buildQuestionMessage(request.question)],
|
||||
request.abortController.signal,
|
||||
);
|
||||
const context: Context = {
|
||||
systemPrompt: this.ctx.session.systemPrompt,
|
||||
messages: llmMessages,
|
||||
};
|
||||
const options = this.ctx.session.prepareSimpleStreamOptions({
|
||||
apiKey,
|
||||
sessionId: this.ctx.session.sessionId,
|
||||
reasoning: toReasoningEffort(this.ctx.session.thinkingLevel),
|
||||
serviceTier: this.ctx.session.serviceTier,
|
||||
signal: request.abortController.signal,
|
||||
toolChoice: "none",
|
||||
});
|
||||
const stream = this.#streamFn(model, context, options);
|
||||
|
||||
for await (const event of stream) {
|
||||
if (!this.#isActiveRequest(request)) {
|
||||
return;
|
||||
}
|
||||
if (event.type === "text_delta") {
|
||||
request.component.appendText(event.delta);
|
||||
continue;
|
||||
}
|
||||
if (event.type === "done") {
|
||||
const finalText = this.#assistantText(event.message);
|
||||
if (finalText) {
|
||||
request.component.setAnswer(finalText);
|
||||
}
|
||||
request.component.markComplete();
|
||||
return;
|
||||
}
|
||||
if (event.type === "error") {
|
||||
if (event.reason === "aborted" || request.abortController.signal.aborted) {
|
||||
request.component.markAborted();
|
||||
} else {
|
||||
request.component.markError(
|
||||
this.#assistantText(event.error) || event.error.errorMessage || "BTW request failed.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.#isActiveRequest(request)) {
|
||||
return;
|
||||
}
|
||||
if (request.abortController.signal.aborted) {
|
||||
request.component.markAborted();
|
||||
return;
|
||||
}
|
||||
request.component.markError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
#buildQuestionMessage(question: string): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: renderPromptTemplate(btwUserPrompt, { question }),
|
||||
},
|
||||
],
|
||||
attribution: "user",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
#buildMessageSnapshot(): AgentMessage[] {
|
||||
const messages = this.ctx.session.messages.slice();
|
||||
if (!this.ctx.session.isStreaming || !this.ctx.streamingMessage) {
|
||||
return messages;
|
||||
}
|
||||
const streamingText = this.ctx.extractAssistantText(this.ctx.streamingMessage);
|
||||
const lastMessage = messages.at(-1);
|
||||
if (!streamingText) {
|
||||
return lastMessage?.role === "assistant" ? messages.slice(0, -1) : messages;
|
||||
}
|
||||
const normalizedStreamingMessage: AssistantMessage = {
|
||||
...this.ctx.streamingMessage,
|
||||
content: [{ type: "text", text: streamingText }],
|
||||
};
|
||||
if (lastMessage?.role === "assistant") {
|
||||
return [...messages.slice(0, -1), normalizedStreamingMessage];
|
||||
}
|
||||
return [...messages, normalizedStreamingMessage];
|
||||
}
|
||||
|
||||
#assistantText(message: AssistantMessage): string {
|
||||
let text = "";
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text") {
|
||||
text += content.text;
|
||||
}
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
#closeActiveRequest(options: { abort: boolean }): void {
|
||||
const request = this.#activeRequest;
|
||||
if (!request) return;
|
||||
this.#activeRequest = undefined;
|
||||
if (options.abort) {
|
||||
request.abortController.abort();
|
||||
}
|
||||
request.component.close();
|
||||
this.ctx.btwContainer.clear();
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
#isActiveRequest(request: BtwRequest): boolean {
|
||||
return this.#activeRequest === request;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export class InputController {
|
||||
this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
|
||||
Boolean(
|
||||
this.ctx.loadingAnimation ||
|
||||
this.ctx.hasActiveBtw() ||
|
||||
this.ctx.session.isStreaming ||
|
||||
this.ctx.session.isCompacting ||
|
||||
this.ctx.session.isGeneratingHandoff ||
|
||||
@@ -40,6 +41,9 @@ export class InputController {
|
||||
this.ctx.retryEscapeHandler,
|
||||
);
|
||||
this.ctx.editor.onEscape = () => {
|
||||
if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
|
||||
return;
|
||||
}
|
||||
if (this.ctx.loadingAnimation) {
|
||||
if (this.ctx.cancelPendingSubmission()) {
|
||||
return;
|
||||
@@ -435,6 +439,9 @@ export class InputController {
|
||||
this.ctx.showWarning("Agent is idle; nothing to background");
|
||||
return;
|
||||
}
|
||||
if (this.ctx.hasActiveBtw()) {
|
||||
this.ctx.handleBtwEscape();
|
||||
}
|
||||
|
||||
this.ctx.isBackgrounded = true;
|
||||
const backgroundUiContext = this.ctx.createBackgroundUiContext();
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { PythonExecutionComponent } from "./components/python-execution";
|
||||
import { StatusLineComponent } from "./components/status-line";
|
||||
import type { ToolExecutionHandle } from "./components/tool-execution";
|
||||
import { WelcomeComponent } from "./components/welcome";
|
||||
import { BtwController } from "./controllers/btw-controller";
|
||||
import { CommandController } from "./controllers/command-controller";
|
||||
import { EventController } from "./controllers/event-controller";
|
||||
import { ExtensionUiController } from "./controllers/extension-ui-controller";
|
||||
@@ -89,6 +90,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
pendingMessagesContainer: Container;
|
||||
statusContainer: Container;
|
||||
todoContainer: Container;
|
||||
btwContainer: Container;
|
||||
editor: CustomEditor;
|
||||
editorContainer: Container;
|
||||
statusLine: StatusLineComponent;
|
||||
@@ -151,6 +153,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
mcpManager?: import("../mcp").MCPManager;
|
||||
readonly #toolUiContextSetter: (uiContext: ExtensionUIContext, hasUI: boolean) => void;
|
||||
|
||||
readonly #btwController: BtwController;
|
||||
readonly #commandController: CommandController;
|
||||
readonly #eventController: EventController;
|
||||
readonly #extensionUiController: ExtensionUiController;
|
||||
@@ -192,6 +195,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.pendingMessagesContainer = new Container();
|
||||
this.statusContainer = new Container();
|
||||
this.todoContainer = new Container();
|
||||
this.btwContainer = new Container();
|
||||
this.editor = new CustomEditor(getEditorTheme());
|
||||
this.editor.setUseTerminalCursor(this.ui.getShowHardwareCursor());
|
||||
this.editor.setAutocompleteMaxVisible(settings.get("autocompleteMaxVisible"));
|
||||
@@ -248,6 +252,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.#pendingSlashCommands = [...BUILTIN_SLASH_COMMANDS, ...hookCommands, ...customCommands, ...skillCommandList];
|
||||
|
||||
this.#uiHelpers = new UiHelpers(this);
|
||||
this.#btwController = new BtwController(this);
|
||||
this.#extensionUiController = new ExtensionUiController(this);
|
||||
this.#eventController = new EventController(this);
|
||||
this.#commandController = new CommandController(this);
|
||||
@@ -328,6 +333,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.ui.addChild(this.pendingMessagesContainer);
|
||||
this.ui.addChild(this.statusContainer);
|
||||
this.ui.addChild(this.todoContainer);
|
||||
this.ui.addChild(this.btwContainer);
|
||||
this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
|
||||
this.ui.addChild(new Spacer(1));
|
||||
this.ui.addChild(this.editorContainer);
|
||||
@@ -861,6 +867,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
|
||||
// Flush pending session writes before shutdown
|
||||
await this.sessionManager.flush();
|
||||
this.#btwController.dispose();
|
||||
|
||||
// Emit shutdown event to hooks
|
||||
await this.session.dispose();
|
||||
@@ -1073,11 +1080,13 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
|
||||
handleClearCommand(): Promise<void> {
|
||||
this.#btwController.dispose();
|
||||
this.#extensionUiController.clearExtensionTerminalInputListeners();
|
||||
return this.#commandController.handleClearCommand();
|
||||
}
|
||||
|
||||
handleForkCommand(): Promise<void> {
|
||||
this.#btwController.dispose();
|
||||
return this.#commandController.handleForkCommand();
|
||||
}
|
||||
|
||||
@@ -1233,6 +1242,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
|
||||
handleResumeSession(sessionPath: string): Promise<void> {
|
||||
this.#btwController.dispose();
|
||||
return this.#selectorController.handleResumeSession(sessionPath);
|
||||
}
|
||||
|
||||
@@ -1269,6 +1279,18 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
return this.#inputController.handleImagePaste();
|
||||
}
|
||||
|
||||
handleBtwCommand(question: string): Promise<void> {
|
||||
return this.#btwController.start(question);
|
||||
}
|
||||
|
||||
hasActiveBtw(): boolean {
|
||||
return this.#btwController.hasActiveRequest();
|
||||
}
|
||||
|
||||
handleBtwEscape(): boolean {
|
||||
return this.#btwController.handleEscape();
|
||||
}
|
||||
|
||||
cycleThinkingLevel(): void {
|
||||
this.#inputController.cycleThinkingLevel();
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface InteractiveModeContext {
|
||||
pendingMessagesContainer: Container;
|
||||
statusContainer: Container;
|
||||
todoContainer: Container;
|
||||
btwContainer: Container;
|
||||
editor: CustomEditor;
|
||||
editorContainer: Container;
|
||||
statusLine: StatusLineComponent;
|
||||
@@ -205,6 +206,9 @@ export interface InteractiveModeContext {
|
||||
handleDequeue(): void;
|
||||
handleBackgroundCommand(): void;
|
||||
handleImagePaste(): Promise<boolean>;
|
||||
handleBtwCommand(question: string): Promise<void>;
|
||||
hasActiveBtw(): boolean;
|
||||
handleBtwEscape(): boolean;
|
||||
cycleThinkingLevel(): void;
|
||||
cycleRoleModel(options?: { temporary?: boolean }): Promise<void>;
|
||||
toggleToolOutputExpansion(): void;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<btw>
|
||||
This is an ephemeral side question for the current interactive session.
|
||||
Answer briefly and directly using the conversation context already provided.
|
||||
Do not use tools.
|
||||
Do not ask follow-up questions.
|
||||
Question:
|
||||
{{question}}
|
||||
</btw>
|
||||
@@ -1340,6 +1340,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
if (!obfuscator?.hasSecrets()) return converted;
|
||||
return obfuscateMessages(obfuscator, converted);
|
||||
};
|
||||
const transformContext = extensionRunner
|
||||
? async (messages: AgentMessage[], _signal?: AbortSignal) => {
|
||||
return await extensionRunner.emitContext(messages);
|
||||
}
|
||||
: undefined;
|
||||
const onPayload = extensionRunner
|
||||
? async (payload: unknown, _model?: Model) => {
|
||||
return await extensionRunner.emitBeforeProviderRequest(payload);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const setToolUIContext = (uiContext: ExtensionUIContext, hasUI: boolean) => {
|
||||
toolContextStore.setUIContext(uiContext, hasUI);
|
||||
@@ -1362,17 +1372,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
tools: initialTools,
|
||||
},
|
||||
convertToLlm: convertToLlmFinal,
|
||||
onPayload: extensionRunner
|
||||
? async (payload, _model) => {
|
||||
return extensionRunner.emitBeforeProviderRequest(payload);
|
||||
}
|
||||
: undefined,
|
||||
onPayload,
|
||||
sessionId: sessionManager.getSessionId(),
|
||||
transformContext: extensionRunner
|
||||
? async messages => {
|
||||
return extensionRunner.emitContext(messages);
|
||||
}
|
||||
: undefined,
|
||||
transformContext,
|
||||
steeringMode: settings.get("steeringMode") ?? "one-at-a-time",
|
||||
followUpMode: settings.get("followUpMode") ?? "one-at-a-time",
|
||||
interruptMode: settings.get("interruptMode") ?? "immediate",
|
||||
@@ -1447,6 +1449,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
skillsSettings: settings.getGroup("skills") as Required<SkillsSettings>,
|
||||
modelRegistry,
|
||||
toolRegistry,
|
||||
transformContext,
|
||||
onPayload,
|
||||
convertToLlm: convertToLlmFinal,
|
||||
rebuildSystemPrompt,
|
||||
ttsrManager,
|
||||
forceCopilotAgentInitiator,
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
Model,
|
||||
ProviderSessionState,
|
||||
ServiceTier,
|
||||
SimpleStreamOptions,
|
||||
TextContent,
|
||||
ToolCall,
|
||||
ToolChoice,
|
||||
@@ -126,6 +127,7 @@ import {
|
||||
bashExecutionToText,
|
||||
type CompactionSummaryMessage,
|
||||
type CustomMessage,
|
||||
convertToLlm,
|
||||
type FileMentionMessage,
|
||||
type HookMessage,
|
||||
type PythonExecutionMessage,
|
||||
@@ -191,6 +193,12 @@ export interface AgentSessionConfig {
|
||||
modelRegistry: ModelRegistry;
|
||||
/** Tool registry for LSP and settings */
|
||||
toolRegistry?: Map<string, AgentTool>;
|
||||
/** Current session pre-LLM message transform pipeline */
|
||||
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
|
||||
/** Provider payload hook used by the active session request path */
|
||||
onPayload?: SimpleStreamOptions["onPayload"];
|
||||
/** Current session message-to-LLM conversion pipeline */
|
||||
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
/** System prompt builder that can consider tool availability */
|
||||
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>;
|
||||
/** TTSR manager for time-traveling stream rules */
|
||||
@@ -381,6 +389,9 @@ export class AgentSession {
|
||||
|
||||
// Tool registry and prompt builder for extensions
|
||||
#toolRegistry: Map<string, AgentTool>;
|
||||
#transformContext: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
|
||||
#onPayload: SimpleStreamOptions["onPayload"] | undefined;
|
||||
#convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
#rebuildSystemPrompt: ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>) | undefined;
|
||||
#baseSystemPrompt: string;
|
||||
#forceCopilotAgentInitiator = false;
|
||||
@@ -425,6 +436,9 @@ export class AgentSession {
|
||||
this.#skillsSettings = config.skillsSettings;
|
||||
this.#modelRegistry = config.modelRegistry;
|
||||
this.#toolRegistry = config.toolRegistry ?? new Map();
|
||||
this.#transformContext = config.transformContext ?? (messages => messages);
|
||||
this.#onPayload = config.onPayload;
|
||||
this.#convertToLlm = config.convertToLlm ?? convertToLlm;
|
||||
this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
|
||||
this.#baseSystemPrompt = this.agent.state.systemPrompt;
|
||||
this.#ttsrManager = config.ttsrManager;
|
||||
@@ -1714,6 +1728,31 @@ export class AgentSession {
|
||||
return this.agent.state.messages;
|
||||
}
|
||||
|
||||
/** Convert session messages using the same pre-LLM pipeline as the active session. */
|
||||
async convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise<Message[]> {
|
||||
const transformedMessages = await this.#transformContext(messages, signal);
|
||||
return await this.#convertToLlm(transformedMessages);
|
||||
}
|
||||
|
||||
/** Apply session-level stream hooks to a direct side request. */
|
||||
prepareSimpleStreamOptions(options: SimpleStreamOptions): SimpleStreamOptions {
|
||||
if (!this.#onPayload) return options;
|
||||
if (!options.onPayload) {
|
||||
return { ...options, onPayload: this.#onPayload };
|
||||
}
|
||||
const sessionOnPayload = this.#onPayload;
|
||||
const requestOnPayload = options.onPayload;
|
||||
return {
|
||||
...options,
|
||||
onPayload: async (payload, model) => {
|
||||
const sessionPayload = await sessionOnPayload(payload, model);
|
||||
const sessionResolvedPayload = sessionPayload ?? payload;
|
||||
const requestPayload = await requestOnPayload(sessionResolvedPayload, model);
|
||||
return requestPayload ?? sessionResolvedPayload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Current steering mode */
|
||||
get steeringMode(): "all" | "one-at-a-time" {
|
||||
return this.agent.getSteeringMode();
|
||||
|
||||
@@ -465,6 +465,17 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
|
||||
runtime.ctx.editor.setText("");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "btw",
|
||||
description: "Ask an ephemeral side question using the current session context",
|
||||
inlineHint: "<question>",
|
||||
allowArgs: true,
|
||||
handle: async (command, runtime) => {
|
||||
const question = command.text.slice(`/${command.name}`.length).trim();
|
||||
runtime.ctx.editor.setText("");
|
||||
await runtime.ctx.handleBtwCommand(question);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "background",
|
||||
aliases: ["bg"],
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import type { Message, SimpleStreamOptions } from "@oh-my-pi/pi-ai";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
|
||||
function createAgent(): Agent {
|
||||
return new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "system prompt",
|
||||
messages: [],
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("AgentSession message pipeline", () => {
|
||||
const sessions: AgentSession[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const session of sessions.splice(0)) {
|
||||
await session.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies transformContext before convertToLlm", async () => {
|
||||
const inputMessages: AgentMessage[] = [{ role: "user", content: "hello", timestamp: Date.now() }];
|
||||
const transformedMessages: AgentMessage[] = [
|
||||
...inputMessages,
|
||||
{ role: "user", content: "injected context", timestamp: Date.now() },
|
||||
];
|
||||
const convertedMessages: Message[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "converted" }],
|
||||
attribution: "user",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const transformContext = vi.fn(async (messages: AgentMessage[], signal?: AbortSignal) => {
|
||||
expect(signal).toBe(abortController.signal);
|
||||
return [...messages, ...transformedMessages.slice(messages.length)];
|
||||
});
|
||||
const convertToLlm = vi.fn(async (_messages: AgentMessage[]) => {
|
||||
return convertedMessages;
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
const session = new AgentSession({
|
||||
agent: createAgent(),
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settings: Settings.isolated({ "compaction.enabled": false }),
|
||||
modelRegistry: {} as never,
|
||||
transformContext,
|
||||
convertToLlm,
|
||||
});
|
||||
sessions.push(session);
|
||||
|
||||
const result = await session.convertMessagesToLlm(inputMessages, abortController.signal);
|
||||
|
||||
expect(transformContext).toHaveBeenCalledWith(inputMessages, abortController.signal);
|
||||
expect(convertToLlm).toHaveBeenCalledWith(transformedMessages);
|
||||
expect(result).toEqual(convertedMessages);
|
||||
});
|
||||
|
||||
it("composes session payload hooks into direct side-request options", async () => {
|
||||
const sessionOnPayload = vi.fn(async (payload: unknown) => ({
|
||||
...(payload as Record<string, unknown>),
|
||||
session: true,
|
||||
}));
|
||||
const requestOnPayload = vi.fn(async () => undefined);
|
||||
const session = new AgentSession({
|
||||
agent: createAgent(),
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settings: Settings.isolated({ "compaction.enabled": false }),
|
||||
modelRegistry: {} as never,
|
||||
onPayload: sessionOnPayload,
|
||||
});
|
||||
sessions.push(session);
|
||||
const options: SimpleStreamOptions = {
|
||||
apiKey: "key",
|
||||
onPayload: requestOnPayload,
|
||||
};
|
||||
|
||||
const prepared = session.prepareSimpleStreamOptions(options);
|
||||
const result = await prepared.onPayload?.({ original: true });
|
||||
|
||||
expect(sessionOnPayload).toHaveBeenCalledWith({ original: true }, undefined);
|
||||
expect(requestOnPayload).toHaveBeenCalledWith({ original: true, session: true }, undefined);
|
||||
expect(result).toEqual({ original: true, session: true });
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,11 @@ function createContext(): {
|
||||
cancelPendingSubmission: ReturnType<typeof vi.fn>;
|
||||
clearQueue: ReturnType<typeof vi.fn>;
|
||||
ensureLoadingAnimation: ReturnType<typeof vi.fn>;
|
||||
handleBtwCommand: ReturnType<typeof vi.fn>;
|
||||
handleBtwEscape: ReturnType<typeof vi.fn>;
|
||||
hasActiveBtw: ReturnType<typeof vi.fn>;
|
||||
onInputCallback: ReturnType<typeof vi.fn>;
|
||||
prompt: ReturnType<typeof vi.fn>;
|
||||
requestRender: ReturnType<typeof vi.fn>;
|
||||
startPendingSubmission: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
@@ -62,7 +66,11 @@ function createContext(): {
|
||||
const cancelPendingSubmission = vi.fn(() => false);
|
||||
const clearQueue = vi.fn(() => ({ steering: [], followUp: [] }));
|
||||
const onInputCallback = vi.fn();
|
||||
const prompt = vi.fn();
|
||||
const requestRender = vi.fn();
|
||||
const handleBtwCommand = vi.fn(async () => {});
|
||||
const handleBtwEscape = vi.fn(() => true);
|
||||
const hasActiveBtw = vi.fn(() => false);
|
||||
const startPendingSubmission = vi.fn((input: { text: string; images?: InteractiveModeContext["pendingImages"] }) => {
|
||||
ensureLoadingAnimation();
|
||||
return createSubmission(input);
|
||||
@@ -104,6 +112,7 @@ function createContext(): {
|
||||
abortBash,
|
||||
abortPython,
|
||||
clearQueue,
|
||||
prompt,
|
||||
} as unknown as InteractiveModeContext["session"],
|
||||
sessionManager: {
|
||||
getSessionName: () => "existing session",
|
||||
@@ -129,6 +138,9 @@ function createContext(): {
|
||||
toggleTodoExpansion: vi.fn(),
|
||||
handleHotkeysCommand: vi.fn(),
|
||||
handleSTTToggle: vi.fn(),
|
||||
handleBtwEscape,
|
||||
handleBtwCommand,
|
||||
hasActiveBtw,
|
||||
showTreeSelector: vi.fn(),
|
||||
showUserMessageSelector: vi.fn(),
|
||||
showSessionSelector: vi.fn(),
|
||||
@@ -145,7 +157,11 @@ function createContext(): {
|
||||
cancelPendingSubmission,
|
||||
clearQueue,
|
||||
ensureLoadingAnimation,
|
||||
handleBtwCommand,
|
||||
handleBtwEscape,
|
||||
hasActiveBtw,
|
||||
onInputCallback,
|
||||
prompt,
|
||||
requestRender,
|
||||
startPendingSubmission,
|
||||
},
|
||||
@@ -175,6 +191,21 @@ describe("InputController escape behavior", () => {
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs /btw as a builtin side request instead of steering the active stream", async () => {
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupEditorSubmitHandler();
|
||||
editor.setText("/btw why is it doing that?");
|
||||
await editor.onSubmit?.("/btw why is it doing that?");
|
||||
|
||||
expect(spies.handleBtwCommand).toHaveBeenCalledWith("why is it doing that?");
|
||||
expect(spies.prompt).not.toHaveBeenCalled();
|
||||
expect(editor.addToHistory).not.toHaveBeenCalled();
|
||||
expect(editor.getText()).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to aborting the active session when no pending optimistic submission exists", () => {
|
||||
const { ctx, editor, spies } = createContext();
|
||||
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
|
||||
@@ -214,6 +245,51 @@ describe("InputController escape behavior", () => {
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dismisses an active /btw panel before aborting the main stream", () => {
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
spies.hasActiveBtw.mockReturnValue(true);
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
expect(editor.shouldBypassAutocompleteOnEscape?.()).toBe(true);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1);
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dismisses an active /btw panel before canceling a pending optimistic submission", () => {
|
||||
const { ctx, editor, spies } = createContext();
|
||||
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
|
||||
spies.hasActiveBtw.mockReturnValue(true);
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
expect(editor.shouldBypassAutocompleteOnEscape?.()).toBe(true);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1);
|
||||
expect(spies.cancelPendingSubmission).not.toHaveBeenCalled();
|
||||
expect(spies.clearQueue).not.toHaveBeenCalled();
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dismisses an active /btw panel before aborting bash", () => {
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isBashRunning: boolean }).isBashRunning = true;
|
||||
spies.hasActiveBtw.mockReturnValue(true);
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
expect(editor.shouldBypassAutocompleteOnEscape?.()).toBe(true);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1);
|
||||
expect(spies.abortBash).not.toHaveBeenCalled();
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aborts streaming even when the working loader is no longer present", () => {
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "bun:test";
|
||||
import { type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { AssistantMessage, Message, Usage } from "@oh-my-pi/pi-ai";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-ai";
|
||||
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
||||
import { BtwController } from "@oh-my-pi/pi-coding-agent/modes/controllers/btw-controller";
|
||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||
import { Container, type TUI } from "@oh-my-pi/pi-tui";
|
||||
|
||||
const usage: Usage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
function createAssistantMessage(text: string): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
usage,
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createUserMessage(text: string): Message {
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "text", text }],
|
||||
attribution: "user",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await initTheme();
|
||||
});
|
||||
|
||||
describe("BtwController", () => {
|
||||
it("builds a tool-less side request from the current session prefix and preserves payload hooks", async () => {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const convertMessagesToLlm = vi.fn(async (messages: AgentMessage[]) => {
|
||||
const question = messages.at(-1);
|
||||
if (!question || !("content" in question)) {
|
||||
throw new Error("Expected the /btw question to be present in the conversion pipeline");
|
||||
}
|
||||
const questionText =
|
||||
typeof question.content === "string"
|
||||
? question.content
|
||||
: question.content
|
||||
.filter(content => content.type === "text")
|
||||
.map(content => content.text)
|
||||
.join("");
|
||||
return [createUserMessage(sessionMessages[0].content), createUserMessage(questionText)];
|
||||
});
|
||||
const getApiKey = vi.fn(async () => "key");
|
||||
const onPayload = vi.fn(async payload => payload);
|
||||
const prepareSimpleStreamOptions = vi.fn(options => ({ ...options, onPayload }));
|
||||
const prompt = vi.fn();
|
||||
const requestRender = vi.fn();
|
||||
const btwContainer = new Container();
|
||||
const sessionMessages = [{ role: "user" as const, content: "hello", timestamp: Date.now() }];
|
||||
const streamFn = vi.fn((_model, _context, _options) => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Answer") });
|
||||
});
|
||||
return stream;
|
||||
});
|
||||
const ctx = {
|
||||
ui: { requestRender } as unknown as TUI,
|
||||
btwContainer,
|
||||
session: {
|
||||
model,
|
||||
messages: sessionMessages,
|
||||
isStreaming: false,
|
||||
sessionId: "session-1",
|
||||
serviceTier: "priority",
|
||||
thinkingLevel: ThinkingLevel.High,
|
||||
systemPrompt: "system prompt",
|
||||
modelRegistry: { getApiKey } as unknown as InteractiveModeContext["session"]["modelRegistry"],
|
||||
convertMessagesToLlm,
|
||||
prepareSimpleStreamOptions,
|
||||
prompt,
|
||||
} as unknown as InteractiveModeContext["session"],
|
||||
streamingMessage: undefined,
|
||||
extractAssistantText: (message: AssistantMessage) =>
|
||||
message.content
|
||||
.filter(content => content.type === "text")
|
||||
.map(content => content.text)
|
||||
.join(""),
|
||||
showStatus: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
} as unknown as InteractiveModeContext;
|
||||
const controller = new BtwController(ctx, { streamFn });
|
||||
|
||||
await controller.start("What changed?");
|
||||
await Bun.sleep(0);
|
||||
|
||||
const convertCall = convertMessagesToLlm.mock.calls[0] as unknown as [AgentMessage[], AbortSignal];
|
||||
expect(convertCall).toBeDefined();
|
||||
expect(convertCall[0]).toHaveLength(2);
|
||||
expect(convertCall[0][0]).toEqual(ctx.session.messages[0]);
|
||||
expect(convertCall[0][1]?.role).toBe("user");
|
||||
expect(convertCall[1]).toBeInstanceOf(AbortSignal);
|
||||
const appendedQuestion = convertCall[0][1] as {
|
||||
role: "user";
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
expect(appendedQuestion.content[0]?.type).toBe("text");
|
||||
expect(appendedQuestion.content[0]?.text).toContain("What changed?");
|
||||
|
||||
expect(getApiKey).toHaveBeenCalledWith(model, "session-1");
|
||||
expect(prepareSimpleStreamOptions).toHaveBeenCalledTimes(1);
|
||||
expect(streamFn).toHaveBeenCalledTimes(1);
|
||||
const [, context, options] = streamFn.mock.calls[0] as [
|
||||
unknown,
|
||||
{ systemPrompt?: string; messages: Message[] },
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(context.systemPrompt).toBe("system prompt");
|
||||
expect(context.messages).toHaveLength(2);
|
||||
expect((context.messages[1]?.content as Array<{ type: string; text?: string }>)[0]?.text).toContain(
|
||||
"What changed?",
|
||||
);
|
||||
expect(options.apiKey).toBe("key");
|
||||
expect(options.sessionId).toBe("session-1");
|
||||
expect(options.serviceTier).toBe("priority");
|
||||
expect(options.reasoning).toBe(ThinkingLevel.High);
|
||||
expect(options.toolChoice).toBe("none");
|
||||
expect(options.onPayload).toBe(onPayload);
|
||||
expect("providerSessionState" in options).toBe(false);
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
expect(ctx.session.messages).toEqual(sessionMessages);
|
||||
expect(btwContainer.children).toHaveLength(1);
|
||||
expect(controller.hasActiveRequest()).toBe(true);
|
||||
});
|
||||
|
||||
it("appends the active streaming assistant snapshot when session history still ends with the user message", async () => {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const convertMessagesToLlm = vi.fn(async () => []);
|
||||
const streamFn = vi.fn((_model, _context, _options) => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
|
||||
});
|
||||
return stream;
|
||||
});
|
||||
const streamingMessage = {
|
||||
...createAssistantMessage("partial answer"),
|
||||
content: [
|
||||
{ type: "text", text: "partial answer" },
|
||||
{ type: "toolCall", id: "tool-1", name: "read", arguments: {} },
|
||||
],
|
||||
} as AssistantMessage;
|
||||
const ctx = {
|
||||
ui: { requestRender: vi.fn() } as unknown as TUI,
|
||||
btwContainer: new Container(),
|
||||
session: {
|
||||
model,
|
||||
messages: [createUserMessage("latest user")],
|
||||
isStreaming: true,
|
||||
sessionId: "session-1",
|
||||
serviceTier: undefined,
|
||||
thinkingLevel: ThinkingLevel.Off,
|
||||
systemPrompt: "system prompt",
|
||||
modelRegistry: {
|
||||
getApiKey: async () => "key",
|
||||
} as unknown as InteractiveModeContext["session"]["modelRegistry"],
|
||||
convertMessagesToLlm,
|
||||
prepareSimpleStreamOptions: (options =>
|
||||
options) as InteractiveModeContext["session"]["prepareSimpleStreamOptions"],
|
||||
} as unknown as InteractiveModeContext["session"],
|
||||
streamingMessage,
|
||||
extractAssistantText: () => "partial answer",
|
||||
showStatus: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
} as unknown as InteractiveModeContext;
|
||||
const controller = new BtwController(ctx, { streamFn });
|
||||
|
||||
await controller.start("Why?");
|
||||
await Bun.sleep(0);
|
||||
|
||||
const firstCall = convertMessagesToLlm.mock.calls[0] as unknown as [AgentMessage[], AbortSignal];
|
||||
expect(firstCall).toBeDefined();
|
||||
expect(firstCall[1]).toBeInstanceOf(AbortSignal);
|
||||
const snapshot = firstCall[0];
|
||||
expect(snapshot).toHaveLength(3);
|
||||
expect(snapshot[0]?.role).toBe("user");
|
||||
const normalizedAssistant = snapshot[1] as AssistantMessage;
|
||||
expect(normalizedAssistant.role).toBe("assistant");
|
||||
expect(normalizedAssistant.content).toEqual([{ type: "text", text: "partial answer" }]);
|
||||
const appendedQuestion = snapshot[2] as { role: "user"; content: Array<{ type: string; text?: string }> };
|
||||
expect(appendedQuestion.role).toBe("user");
|
||||
expect(appendedQuestion.content[0]?.text).toContain("Why?");
|
||||
});
|
||||
|
||||
it("replaces an existing request by aborting the previous btw stream and keeping one panel", async () => {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const firstStream = new AssistantMessageEventStream();
|
||||
const secondStream = new AssistantMessageEventStream();
|
||||
const btwContainer = new Container();
|
||||
const streamFn = vi
|
||||
.fn()
|
||||
.mockImplementationOnce((_model, _context, _options) => firstStream)
|
||||
.mockImplementationOnce((_model, _context, _options) => {
|
||||
queueMicrotask(() => {
|
||||
secondStream.push({ type: "done", reason: "stop", message: createAssistantMessage("Second") });
|
||||
});
|
||||
return secondStream;
|
||||
});
|
||||
const ctx = {
|
||||
ui: { requestRender: vi.fn() } as unknown as TUI,
|
||||
btwContainer,
|
||||
session: {
|
||||
model,
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
sessionId: "session-1",
|
||||
serviceTier: undefined,
|
||||
thinkingLevel: ThinkingLevel.Off,
|
||||
systemPrompt: "system prompt",
|
||||
modelRegistry: {
|
||||
getApiKey: async () => "key",
|
||||
} as unknown as InteractiveModeContext["session"]["modelRegistry"],
|
||||
convertMessagesToLlm: async () => [],
|
||||
prepareSimpleStreamOptions: (options =>
|
||||
options) as InteractiveModeContext["session"]["prepareSimpleStreamOptions"],
|
||||
} as unknown as InteractiveModeContext["session"],
|
||||
streamingMessage: undefined,
|
||||
extractAssistantText: vi.fn(),
|
||||
showStatus: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
} as unknown as InteractiveModeContext;
|
||||
const controller = new BtwController(ctx, { streamFn });
|
||||
|
||||
await controller.start("First?");
|
||||
await controller.start("Second?");
|
||||
await Bun.sleep(0);
|
||||
|
||||
const firstOptions = streamFn.mock.calls[0]?.[2] as { signal: AbortSignal };
|
||||
expect(firstOptions.signal.aborted).toBe(true);
|
||||
expect(streamFn).toHaveBeenCalledTimes(2);
|
||||
expect(btwContainer.children).toHaveLength(1);
|
||||
expect(controller.hasActiveRequest()).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the btw panel when the active request is dismissed", async () => {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const btwContainer = new Container();
|
||||
const streamFn = vi.fn((_model, _context, _options) => new AssistantMessageEventStream());
|
||||
const ctx = {
|
||||
ui: { requestRender: vi.fn() } as unknown as TUI,
|
||||
btwContainer,
|
||||
session: {
|
||||
model,
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
sessionId: "session-1",
|
||||
serviceTier: undefined,
|
||||
thinkingLevel: ThinkingLevel.Off,
|
||||
systemPrompt: "system prompt",
|
||||
modelRegistry: {
|
||||
getApiKey: async () => "key",
|
||||
} as unknown as InteractiveModeContext["session"]["modelRegistry"],
|
||||
convertMessagesToLlm: async () => [],
|
||||
prepareSimpleStreamOptions: (options =>
|
||||
options) as InteractiveModeContext["session"]["prepareSimpleStreamOptions"],
|
||||
} as unknown as InteractiveModeContext["session"],
|
||||
streamingMessage: undefined,
|
||||
extractAssistantText: vi.fn(),
|
||||
showStatus: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
} as unknown as InteractiveModeContext;
|
||||
const controller = new BtwController(ctx, { streamFn });
|
||||
|
||||
await controller.start("Question?");
|
||||
|
||||
expect(btwContainer.children).toHaveLength(1);
|
||||
expect(controller.handleEscape()).toBe(true);
|
||||
expect(btwContainer.children).toHaveLength(0);
|
||||
expect(controller.hasActiveRequest()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from "bun:test";
|
||||
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||
import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry";
|
||||
|
||||
function createRuntime() {
|
||||
const handleBtwCommand = vi.fn(async () => {});
|
||||
const setText = vi.fn();
|
||||
return {
|
||||
handleBtwCommand,
|
||||
setText,
|
||||
runtime: {
|
||||
ctx: {
|
||||
editor: { setText } as unknown as InteractiveModeContext["editor"],
|
||||
handleBtwCommand,
|
||||
} as unknown as InteractiveModeContext,
|
||||
handleBackgroundCommand: () => {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("/btw slash command", () => {
|
||||
it("routes the full question through the interactive btw handler", async () => {
|
||||
const harness = createRuntime();
|
||||
|
||||
const handled = await executeBuiltinSlashCommand("/btw why is it doing that?", harness.runtime);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(harness.setText).toHaveBeenCalledWith("");
|
||||
expect(harness.handleBtwCommand).toHaveBeenCalledWith("why is it doing that?");
|
||||
});
|
||||
|
||||
it("preserves the raw multi-word suffix after /btw", async () => {
|
||||
const harness = createRuntime();
|
||||
|
||||
const handled = await executeBuiltinSlashCommand(
|
||||
"/btw explain why the cache reuse matters here",
|
||||
harness.runtime,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(harness.handleBtwCommand).toHaveBeenCalledWith("explain why the cache reuse matters here");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user