feat: implemented session account selection and pinning via slash command

- Add `pinSessionOAuthAccount` storage method and active account flag to the auth storage API.
- Introduce session account selector component, controller logic, and interactive mode delegation.
- Implement the `/session pin` builtin slash command with text listing and account pinning capabilities.
- Add unit tests covering session account selection, component navigation, and command handling.
This commit is contained in:
can1357
2026-07-24 07:56:40 +02:00
parent 22a2ea1699
commit af9e8546a9
15 changed files with 459 additions and 10 deletions
+1
View File
@@ -4,6 +4,7 @@
### Added
- Added OAuth account session pinning and active status tracking in storage
- Added OpenAI Responses native computer-use transport, including batched actions and exact `computer_call`/`computer_call_output` replay with pending/acknowledged safety checks and `image_url`/`file_id` output references. Models without native support receive the same action surface as a regular function tool; provider-specific tool-choice forcing is used where supported.
- Added `PI_CODEX_RESPONSES_LITE` to override the catalog-selected Codex Responses transport for diagnostics (`1`/`true` forces Lite; `0`/`false` forces the standard body).
+32 -1
View File
@@ -830,6 +830,8 @@ export interface OAuthAccountSummary {
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
orgId?: string;
orgName?: string;
/** True when this account is the session-sticky OAuth credential requested by `listOAuthAccounts`. */
active: boolean;
}
export interface InvalidateCredentialMatchingOptions {
signal?: AbortSignal;
@@ -5202,11 +5204,20 @@ export class AuthStorage {
* order, WITHOUT refreshing any token. The array position (0-based) is the
* selector accepted by {@link AuthStorage.getOAuthAccessAt}; a "pick the Nth
* account" UI should render `position + 1`.
*
* When `sessionId` is supplied, the session-sticky OAuth credential is marked
* `active`. No account is active before that session has resolved or pinned a
* credential.
*/
listOAuthAccounts(provider: string): OAuthAccountSummary[] {
listOAuthAccounts(provider: string, sessionId?: string): OAuthAccountSummary[] {
if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
return [];
}
const sessionCredential = this.#getSessionCredential(provider, sessionId);
const activeCredentialId =
sessionCredential?.type === "oauth"
? this.#getStoredCredentials(provider)[sessionCredential.index]?.id
: undefined;
return this.#getStoredOAuthSelections(provider).map((selection, position) => ({
position,
credentialId: selection.credentialId,
@@ -5216,9 +5227,29 @@ export class AuthStorage {
enterpriseUrl: selection.credential.enterpriseUrl,
orgId: selection.credential.orgId,
orgName: selection.credential.orgName,
active: selection.credentialId === activeCredentialId,
}));
}
/**
* Pin one stored OAuth account as this session's preferred credential.
*
* The durable credential id keeps the pin stable across credential refreshes,
* storage reordering, and process restarts. Normal auth retry and usage-limit
* handling may still route around an unavailable account.
*/
pinSessionOAuthAccount(provider: string, sessionId: string, credentialId: number): boolean {
if (!sessionId || this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
return false;
}
const stored = this.#getStoredCredentials(provider);
const index = stored.findIndex(entry => entry.id === credentialId);
const target = stored[index];
if (!target || target.credential.type !== "oauth") return false;
this.#recordSessionCredential(provider, sessionId, "oauth", index);
return true;
}
/**
* Resolve every stored OAuth credential for `provider` independently.
*
@@ -3,6 +3,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai/auth-storage";
import { withOAuthAccess } from "@oh-my-pi/pi-ai/auth-retry";
import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth";
const PROVIDER = "unit-oauth-select";
@@ -55,6 +56,43 @@ describe("AuthStorage OAuth account selection", () => {
expect(refreshSpy).not.toHaveBeenCalled();
});
test("pinSessionOAuthAccount selects and restores the exact stored account", async () => {
const storage = authStorage;
const credentialStore = store;
if (!storage || !credentialStore) throw new Error("test setup failed");
vi.spyOn(oauthUtils, "getOAuthApiKey").mockImplementation(async (provider, credentials) => {
const credential = credentials[provider];
return credential ? { newCredentials: credential, apiKey: credential.access } : null;
});
await storage.set(PROVIDER, [oauthCredential("a"), oauthCredential("b"), oauthCredential("c")]);
const accounts = storage.listOAuthAccounts(PROVIDER, "session-pin");
const target = accounts[1];
if (!target) throw new Error("expected second OAuth account");
expect(accounts.some(account => account.active)).toBe(false);
expect(storage.pinSessionOAuthAccount(PROVIDER, "session-pin", -1)).toBe(false);
expect(storage.pinSessionOAuthAccount(PROVIDER, "session-pin", target.credentialId)).toBe(true);
expect(storage.getOAuthAccountIdentity(PROVIDER, "session-pin")?.email).toBe("b@example.com");
expect(
storage
.listOAuthAccounts(PROVIDER, "session-pin")
.filter(account => account.active)
.map(account => account.email),
).toEqual(["b@example.com"]);
expect(
await withOAuthAccess(storage, PROVIDER, access => Promise.resolve(access.email), {
sessionId: "session-pin",
}),
).toBe("b@example.com");
const restored = new AuthStorage(credentialStore);
await restored.reload();
expect(restored.getOAuthAccountIdentity(PROVIDER, "session-pin")?.email).toBe("b@example.com");
expect(restored.listOAuthAccounts(PROVIDER, "session-pin").find(account => account.active)?.credentialId).toBe(
target.credentialId,
);
});
test("getOAuthAccessAt resolves the credential at the requested position and touches only that one", async () => {
const storage = authStorage;
if (!storage) throw new Error("test setup failed");
+1
View File
@@ -4,6 +4,7 @@
### Added
- Added the `/session pin` subcommand and account picker to pin provider OAuth accounts for the current session
- Added the disabled-by-default `computer` essential tool with configurable enablement, backend, display, and maximum width/height settings. Native desktop execution runs through a `DesktopSession` worker; observation uses read approval, input uses exec approval, and provider checks always prompt and fail closed.
- Added the `/computer` slash command (`on`/`off`/`status`/toggle) to enable or disable the computer tool for the current session without persisting settings.
- Exposed `computer` to models without native OpenAI computer-use support as a regular function tool with a typed GA action schema; the same native desktop backend and approval policy apply on both paths.
@@ -0,0 +1,62 @@
import { Container, type SelectItem, SelectList, type SgrMouseEvent, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
import { getSelectListTheme, theme } from "../../modes/theme/theme";
import type { SessionPinAccount } from "../../slash-commands/helpers/session-pin";
import { DynamicBorder } from "./dynamic-border";
const ACCOUNT_SELECTOR_MAX_VISIBLE = 10;
const ACCOUNT_LIST_ROW_OFFSET = 4;
/** Account picker opened by `/session pin` for the current model provider. */
export class SessionAccountSelectorComponent extends Container {
#selectList: SelectList;
constructor(
providerName: string,
accounts: readonly SessionPinAccount[],
onSelect: (account: SessionPinAccount) => void,
onCancel: () => void,
) {
super();
const accountsByValue = new Map<string, SessionPinAccount>();
const items: SelectItem[] = accounts.map(account => {
const value = String(account.credentialId);
accountsByValue.set(value, account);
return {
value,
label: account.label,
description: account.active ? "active for this session" : undefined,
};
});
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
this.addChild(new TruncatedText(theme.bold(`Select a ${providerName} account for this session:`)));
this.addChild(new Spacer(1));
this.#selectList = new SelectList(
items,
Math.min(Math.max(items.length, 1), ACCOUNT_SELECTOR_MAX_VISIBLE),
getSelectListTheme(),
);
const activeIndex = accounts.findIndex(account => account.active);
if (activeIndex >= 0) this.#selectList.setSelectedIndex(activeIndex);
this.#selectList.onSelect = item => {
const account = accountsByValue.get(item.value);
if (account) onSelect(account);
};
this.#selectList.onCancel = onCancel;
this.addChild(this.#selectList);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
}
/** Forward keyboard navigation and cancellation when the wrapper owns focus. */
handleInput(keyData: string): void {
this.#selectList.handleInput(keyData);
}
/** Route mouse selection through the title rows into the account list. */
routeMouse(event: SgrMouseEvent, line: number, col: number): void {
this.#selectList.routeMouse(event, line - ACCOUNT_LIST_ROW_OFFSET, col);
}
}
@@ -41,6 +41,7 @@ import {
} from "../../modes/theme/theme";
import type { InteractiveModeContext } from "../../modes/types";
import type { ResetCreditAccountStatus, ResetCreditRedeemOutcome } from "../../session/auth-storage";
import type { SessionOAuthAccountList } from "../../session/agent-session-types";
import type { SessionInfo } from "../../session/session-listing";
import { SessionManager } from "../../session/session-manager";
import { FileSessionStorage } from "../../session/session-storage";
@@ -50,6 +51,7 @@ import {
type ResetUsageAccount,
toResetUsageAccounts,
} from "../../slash-commands/helpers/reset-usage";
import { toSessionPinAccounts } from "../../slash-commands/helpers/session-pin";
import {
AUTO_THINKING,
type ConfiguredThinkingLevel,
@@ -84,6 +86,7 @@ import { OAuthSelectorComponent } from "../components/oauth-selector";
import { PluginSelectorComponent } from "../components/plugin-selector";
import { ResetUsageSelectorComponent } from "../components/reset-usage-selector";
import { renderSegmentTrack } from "../components/segment-track";
import { SessionAccountSelectorComponent } from "../components/session-account-selector";
import { SessionSelectorComponent } from "../components/session-selector";
import { SettingsSelectorComponent } from "../components/settings-selector";
import { ToolExecutionComponent } from "../components/tool-execution";
@@ -1743,6 +1746,65 @@ export class SelectorController {
});
}
async showSessionPinSelector(): Promise<void> {
const session = this.ctx.session;
if (session.isStreaming) {
this.ctx.showStatus("Cannot pin an account while the session is streaming.");
return;
}
this.ctx.showStatus("Loading provider accounts…", { dim: true });
let accountList: SessionOAuthAccountList | undefined;
try {
accountList = await session.listCurrentProviderOAuthAccounts();
} catch (error) {
this.ctx.showError(
`Could not load provider accounts: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}
if (!accountList) {
this.ctx.showStatus("Select a model before pinning a provider account.");
return;
}
const provider = getOAuthProviders().find(candidate => candidate.id === accountList.provider);
const providerName = provider?.name ?? accountList.provider;
const accounts = toSessionPinAccounts(accountList.accounts);
if (accounts.length === 0) {
const source = session.modelRegistry.authStorage.describeCredentialSource(
accountList.provider,
session.sessionId,
);
this.ctx.showStatus(
source
? `No stored OAuth accounts for ${providerName}. Current auth comes from ${source}.`
: `No stored OAuth accounts for ${providerName}. Use /login to add one.`,
);
return;
}
this.showSelector(done => {
const selector = new SessionAccountSelectorComponent(
providerName,
accounts,
account => {
done();
if (!session.pinCurrentProviderOAuthAccount(account.credentialId)) {
this.ctx.showWarning(`${account.label} is no longer available to pin.`);
return;
}
this.ctx.showStatus(`Pinned ${account.label} to this session for ${providerName}.`);
this.ctx.statusLine.invalidate();
this.ctx.ui.requestRender();
},
() => {
done();
this.ctx.ui.requestRender();
},
);
return { component: selector, focus: selector };
});
}
async showResetUsageSelector(): Promise<void> {
const session = this.ctx.session;
this.ctx.showStatus("Checking saved rate-limit resets…", { dim: true });
@@ -4639,6 +4639,10 @@ export class InteractiveMode implements InteractiveModeContext {
return this.#selectorController.showOAuthSelector(mode, providerId);
}
showSessionPinSelector(): Promise<void> {
return this.#selectorController.showSessionPinSelector();
}
showResetUsageSelector(): Promise<void> {
return this.#selectorController.showResetUsageSelector();
}
+1
View File
@@ -383,6 +383,7 @@ export interface InteractiveModeContext {
handleResumeSession(sessionPath: string): Promise<void>;
handleSessionDeleteCommand(): Promise<void>;
showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise<void>;
showSessionPinSelector(): Promise<void>;
showResetUsageSelector(): Promise<void>;
showProviderSetup(): Promise<void>;
showHookConfirm(title: string, message: string): Promise<boolean>;
@@ -5,6 +5,7 @@ import type {
Message,
MessageAttribution,
Model,
OAuthAccountSummary,
ServiceTierByFamily,
SimpleStreamOptions,
ToolChoice,
@@ -325,6 +326,12 @@ export interface SessionStats {
contextUsage?: ContextUsage;
}
/** Stored OAuth accounts available to the current model provider. */
export interface SessionOAuthAccountList {
provider: string;
accounts: OAuthAccountSummary[];
}
/** IDs for a newly created session and the session it replaced. */
export interface FreshSessionResult {
previousSessionId: string;
@@ -218,6 +218,7 @@ import type {
RoleModelCycle,
RoleModelCycleResult,
SessionHandoffOptions,
SessionOAuthAccountList,
SessionStats,
UsageFallbackConfirmation,
} from "./agent-session-types";
@@ -7775,6 +7776,28 @@ export class AgentSession {
return [...selectors].sort((left, right) => left.localeCompare(right));
}
/** List stored OAuth accounts for the current model provider and mark this session's active account. */
async listCurrentProviderOAuthAccounts(): Promise<SessionOAuthAccountList | undefined> {
const provider = this.model?.provider;
if (!provider) return undefined;
const authStorage = this.#modelRegistry.authStorage;
await authStorage.reload();
return {
provider,
accounts: authStorage.listOAuthAccounts(provider, this.sessionId),
};
}
/**
* Pin a stored OAuth account to the current model provider for this session.
* Returns false while streaming or when the credential is no longer available.
*/
pinCurrentProviderOAuthAccount(credentialId: number): boolean {
const provider = this.model?.provider;
if (!provider || this.isStreaming) return false;
return this.#modelRegistry.authStorage.pinSessionOAuthAccount(provider, this.sessionId, credentialId);
}
/**
* Redeem one saved Codex rate-limit reset for a specific account, injecting
* the provider base URL like {@link AgentSession.fetchUsageReports}. Powers
@@ -12,6 +12,7 @@ export type {
AuthStorageOptions,
CredentialOrigin,
CredentialOriginKind,
OAuthAccountSummary,
OAuthAccountIdentity,
OAuthCredential,
ResetCreditAccountStatus,
@@ -33,6 +33,7 @@ import { theme } from "../modes/theme/theme";
import type { InteractiveModeContext } from "../modes/types";
import { extractLastCodeBlock, extractLastCommand } from "../modes/utils/copy-targets";
import type { AgentSession, FreshSessionResult } from "../session/agent-session";
import type { SessionOAuthAccountList } from "../session/agent-session-types";
import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
import { resolveResumableSession } from "../session/session-listing";
import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
@@ -52,6 +53,7 @@ import { createMarketplaceManager } from "./helpers/marketplace-manager";
import { handleMcpAcp } from "./helpers/mcp";
import { commandConsumed, errorMessage, parseSlashCommand, parseSubcommand, usage } from "./helpers/parse";
import { describeRedeemOutcome, type ResetUsageAccount, toResetUsageAccounts } from "./helpers/reset-usage";
import { matchSessionPinAccounts, toSessionPinAccounts } from "./helpers/session-pin";
import { handleSshAcp } from "./helpers/ssh";
import { launchStatsDashboard, parseStatsDashboardArgs } from "./helpers/stats-dashboard";
import { handleTodoAcp } from "./helpers/todo";
@@ -216,6 +218,74 @@ async function handleUsageResetCommand(
await output(describeRedeemOutcome(outcome, target.label));
}
async function handleSessionPinCommand(
arg: string,
session: AgentSession,
output: SlashCommandRuntime["output"],
): Promise<void> {
if (session.isStreaming) {
await output("Cannot pin an account while the session is streaming.");
return;
}
let accountList: SessionOAuthAccountList | undefined;
try {
accountList = await session.listCurrentProviderOAuthAccounts();
} catch (error) {
await output(`Could not load provider accounts: ${errorMessage(error)}`);
return;
}
if (!accountList) {
await output("Select a model before pinning a provider account.");
return;
}
const provider = getOAuthProviders().find(candidate => candidate.id === accountList.provider);
const providerName = provider?.name ?? accountList.provider;
const accounts = toSessionPinAccounts(accountList.accounts);
if (accounts.length === 0) {
const source = session.modelRegistry.authStorage.describeCredentialSource(
accountList.provider,
session.sessionId,
);
await output(
source
? `No stored OAuth accounts for ${providerName}. Current auth comes from ${source}.`
: `No stored OAuth accounts for ${providerName}. Use /login to add one.`,
);
return;
}
const selector = arg.trim();
if (!selector) {
const lines = [`OAuth accounts for ${providerName}:`];
for (const account of accounts) {
lines.push(`${account.position + 1}. ${account.label}${account.active ? " (active)" : ""}`);
}
lines.push("", "Pin one with `/session pin <number|email|account id>`.");
await output(lines.join("\n"));
return;
}
const matches = matchSessionPinAccounts(accounts, selector);
if (matches.length === 0) {
await output(`No ${providerName} account matches "${selector}".`);
return;
}
if (matches.length > 1) {
await output(
`"${selector}" matches multiple ${providerName} accounts: ${matches
.map(account => `${account.position + 1}. ${account.label}`)
.join(", ")}. Use the account number.`,
);
return;
}
const account = matches[0];
if (!account || !session.pinCurrentProviderOAuthAccount(account.credentialId)) {
await output(`${account?.label ?? selector} is no longer available to pin.`);
return;
}
await output(`Pinned ${account.label} to this session for ${providerName}.`);
}
/** Parse the `/shake` subcommand into a {@link ShakeMode}; empty defaults to elide. */
function parseShakeMode(args: string): ShakeMode | { error: string } {
const verb = args.trim().toLowerCase();
@@ -1044,15 +1114,21 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
{
name: "session",
description: "Session management commands",
acpDescription: "Show session information",
acpInputHint: "info|delete",
acpDescription: "Show or configure the current session",
acpInputHint: "[info|delete|pin [account]]",
subcommands: [
{ name: "info", description: "Show session info and stats" },
{ name: "delete", description: "Delete current session and return to selector" },
{
name: "pin",
description: "Pin the current provider to a stored OAuth account",
usage: "[account]",
},
],
allowArgs: true,
handle: async (command, runtime) => {
if (!command.args || command.args === "info") {
const { verb, rest } = parseSubcommand(command.args);
if (!verb || (verb === "info" && !rest)) {
await runtime.output(
[
`Session: ${runtime.session.sessionId}`,
@@ -1062,7 +1138,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
);
return commandConsumed();
}
if (command.args === "delete") {
if (verb === "delete" && !rest) {
if (runtime.session.isStreaming) return usage("Cannot delete the session while streaming.", runtime);
const sessionFile = runtime.sessionManager.getSessionFile();
if (!sessionFile) return usage("No session file to delete (in-memory session).", runtime);
@@ -1081,17 +1157,34 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
);
return commandConsumed();
}
return usage("Usage: /session [info|delete]", runtime);
if (verb === "pin") {
await handleSessionPinCommand(rest, runtime.session, runtime.output);
return commandConsumed();
}
return usage("Usage: /session [info|delete|pin [account]]", runtime);
},
handleTui: async (command, runtime) => {
const sub = command.args.trim().toLowerCase() || "info";
if (sub === "delete") {
const { verb, rest } = parseSubcommand(command.args);
if (verb === "delete" && !rest) {
runtime.ctx.editor.setText("");
await runtime.ctx.handleSessionDeleteCommand();
return;
}
// Default: show session info
await runtime.ctx.handleSessionCommand();
if (verb === "pin") {
if (rest) {
await handleSessionPinCommand(rest, runtime.ctx.session, text => runtime.ctx.showStatus(text));
refreshStatusLine(runtime.ctx);
} else {
await runtime.ctx.showSessionPinSelector();
}
runtime.ctx.editor.setText("");
return;
}
if (!verb || (verb === "info" && !rest)) {
await runtime.ctx.handleSessionCommand();
} else {
runtime.ctx.showStatus("Usage: /session [info|delete|pin [account]]");
}
runtime.ctx.editor.setText("");
},
},
@@ -0,0 +1,44 @@
import type { OAuthAccountSummary } from "../../session/auth-storage";
import { formatActiveAccountLabel } from "./active-oauth-account";
/** Stored OAuth account rendered and matched by `/session pin`. */
export interface SessionPinAccount extends OAuthAccountSummary {
label: string;
}
/** Add stable user-facing labels to provider account summaries. */
export function toSessionPinAccounts(accounts: readonly OAuthAccountSummary[]): SessionPinAccount[] {
return accounts.map(account => {
const enterpriseUrl = account.enterpriseUrl?.trim();
return {
...account,
label: (formatActiveAccountLabel(account) ?? enterpriseUrl) || `OAuth credential #${account.credentialId}`,
};
});
}
/** Match a `/session pin` selector by 1-based position or exact account identity. */
export function matchSessionPinAccounts(accounts: readonly SessionPinAccount[], selector: string): SessionPinAccount[] {
const wanted = selector.trim().toLowerCase();
if (!wanted) return [];
if (wanted === "active") return accounts.filter(account => account.active);
if (/^\d+$/.test(wanted)) {
const position = Number(wanted) - 1;
const positioned = accounts.find(account => account.position === position);
if (positioned) return [positioned];
}
return accounts.filter(account =>
[
account.label,
account.email,
account.accountId,
account.projectId,
account.enterpriseUrl,
account.orgId,
account.orgName,
`OAuth credential #${account.credentialId}`,
].some(value => value?.trim().toLowerCase() === wanted),
);
}
@@ -0,0 +1,53 @@
import { beforeAll, describe, expect, it } from "bun:test";
import { SessionAccountSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/session-account-selector";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { toSessionPinAccounts } from "@oh-my-pi/pi-coding-agent/slash-commands/helpers/session-pin";
beforeAll(async () => {
await initTheme();
});
const accounts = toSessionPinAccounts([
{ position: 0, credentialId: 11, email: "first@example.com", active: false },
{ position: 1, credentialId: 12, email: "second@example.com", active: true },
]);
describe("SessionAccountSelectorComponent", () => {
it("handles navigation, selection, Escape, and Ctrl+C while focused", () => {
const selected: number[] = [];
let cancellations = 0;
const component = new SessionAccountSelectorComponent(
"Anthropic",
accounts,
account => selected.push(account.credentialId),
() => {
cancellations += 1;
},
);
component.handleInput("\x1b[A");
component.handleInput("\n");
expect(selected).toEqual([11]);
const escapeComponent = new SessionAccountSelectorComponent(
"Anthropic",
accounts,
() => {},
() => {
cancellations += 1;
},
);
escapeComponent.handleInput("\x1b");
const ctrlCComponent = new SessionAccountSelectorComponent(
"Anthropic",
accounts,
() => {},
() => {
cancellations += 1;
},
);
ctrlCComponent.handleInput("\x03");
expect(cancellations).toBe(2);
});
});
@@ -5,6 +5,7 @@ import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-comm
function createRuntimeHarness(options?: {
handleSessionCommand?: InteractiveModeContext["handleSessionCommand"];
handleSessionDeleteCommand?: InteractiveModeContext["handleSessionDeleteCommand"];
showSessionPinSelector?: InteractiveModeContext["showSessionPinSelector"];
}) {
const setText = vi.fn();
const handleSessionCommand =
@@ -17,16 +18,23 @@ function createRuntimeHarness(options?: {
vi.fn(async () => {
return;
});
const showSessionPinSelector =
options?.showSessionPinSelector ??
vi.fn(async () => {
return;
});
return {
setText,
handleSessionCommand,
handleSessionDeleteCommand,
showSessionPinSelector,
runtime: {
ctx: {
editor: { setText } as unknown as InteractiveModeContext["editor"],
handleSessionCommand,
handleSessionDeleteCommand,
showSessionPinSelector,
} as InteractiveModeContext,
},
};
@@ -58,6 +66,26 @@ describe("/session slash command", () => {
expect(harness.setText).toHaveBeenCalledWith("");
});
it("awaits the session account picker", async () => {
const deferred = Promise.withResolvers<void>();
const showSessionPinSelector = vi.fn(() => deferred.promise);
const harness = createRuntimeHarness({ showSessionPinSelector });
let settled = false;
const execution = executeBuiltinSlashCommand("/session pin", harness.runtime).then(result => {
settled = true;
return result;
});
await Promise.resolve();
expect(showSessionPinSelector).toHaveBeenCalledTimes(1);
expect(harness.setText).not.toHaveBeenCalled();
expect(settled).toBe(false);
deferred.resolve();
expect(await execution).toBe(true);
expect(harness.setText).toHaveBeenCalledWith("");
});
it("propagates session info failures through executeBuiltinSlashCommand", async () => {
const infoError = new Error("info failed");
const handleSessionCommand = vi.fn(async () => {