diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index 51e6134c4..85e61e3f7 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -5,7 +5,7 @@ description: Use when generating changesets in the pythinker-code repository — # Generate Changesets -The only user-facing published package is the CLI: `@pymodel/pythinker-code`. All other `@pymodel/*` packages (sdk, agent-core, kosong, pyaos, oauth, telemetry, and so on) are internal. +The only user-facing published package is the CLI: `@pymodel/pythinker-code`. All other `@pymodel/*` packages (sdk, agent-core, kosong, oauth, telemetry, and so on) are internal. ## 1. Whether to Write diff --git a/.changeset/calm-progress-nudges.md b/.changeset/calm-progress-nudges.md new file mode 100644 index 000000000..e41d9b57a --- /dev/null +++ b/.changeset/calm-progress-nudges.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Stop discouraging short progress notes between tool calls, and count NotifyUser reminders per round of tool calls. diff --git a/.changeset/cancel-video-uploads.md b/.changeset/cancel-video-uploads.md new file mode 100644 index 000000000..295340107 --- /dev/null +++ b/.changeset/cancel-video-uploads.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Cancel in-flight video uploads when a media read is interrupted. diff --git a/.changeset/command-lookup-workspace.md b/.changeset/command-lookup-workspace.md new file mode 100644 index 000000000..2c67a5408 --- /dev/null +++ b/.changeset/command-lookup-workspace.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Skip executables inside the workspace during command lookup instead of failing, including when the working directory is the filesystem root. diff --git a/.changeset/footer-git-refresh.md b/.changeset/footer-git-refresh.md new file mode 100644 index 000000000..b029f5aab --- /dev/null +++ b/.changeset/footer-git-refresh.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Stop the footer from waiting on git status; it now refreshes when the repository changes. diff --git a/.changeset/fork-subagent-cache-prefix.md b/.changeset/fork-subagent-cache-prefix.md new file mode 100644 index 000000000..ef6267146 --- /dev/null +++ b/.changeset/fork-subagent-cache-prefix.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Keep forked subagent requests on the same prefix as the parent so they reuse the prompt cache. diff --git a/.changeset/gemini-thinking-level.md b/.changeset/gemini-thinking-level.md new file mode 100644 index 000000000..423cecfe4 --- /dev/null +++ b/.changeset/gemini-thinking-level.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Use thinking levels for Gemini 4 models, and send the lowest level when thinking is off. diff --git a/.changeset/hooks-session-cwd.md b/.changeset/hooks-session-cwd.md new file mode 100644 index 000000000..944b11643 --- /dev/null +++ b/.changeset/hooks-session-cwd.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Run agent hooks in the session's working directory in the web and desktop apps. diff --git a/.changeset/inline-skill-prompts.md b/.changeset/inline-skill-prompts.md new file mode 100644 index 000000000..b0bc74e3c --- /dev/null +++ b/.changeset/inline-skill-prompts.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Show skills invoked in a prompt as highlighted tokens in the message, and let Ctrl+S steer a skill prompt into a running turn. diff --git a/.changeset/llm-headers-timeout.md b/.changeset/llm-headers-timeout.md new file mode 100644 index 000000000..d2c40e02d --- /dev/null +++ b/.changeset/llm-headers-timeout.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Add PYTHINKER_CODE_LLM_HEADERS_TIMEOUT_MS to set how long a request waits for LLM response headers, for slow non-streaming thinking. diff --git a/.changeset/mcp-default-deferred.md b/.changeset/mcp-default-deferred.md new file mode 100644 index 000000000..f04ca21ab --- /dev/null +++ b/.changeset/mcp-default-deferred.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Add `default_deferred` under `[mcp]` in config.toml to load the tools of every MCP server on demand; it requires the experimental tool-select feature. diff --git a/.changeset/media-tools-after-resume.md b/.changeset/media-tools-after-resume.md new file mode 100644 index 000000000..889344049 --- /dev/null +++ b/.changeset/media-tools-after-resume.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Restore the image and video reading tools after resuming a session. diff --git a/.changeset/model-migration.md b/.changeset/model-migration.md new file mode 100644 index 000000000..88d41b756 --- /dev/null +++ b/.changeset/model-migration.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +When a provider registry retires your default model, switch to the replacement it declares and show its migration notes. diff --git a/.changeset/notify-user-title.md b/.changeset/notify-user-title.md new file mode 100644 index 000000000..fb66f0e92 --- /dev/null +++ b/.changeset/notify-user-title.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Give NotifyUser updates an optional one-line title in the Updates panel. Enable NotifyUser with PYTHINKER_CODE_EXPERIMENTAL_NOTIFY_USER=1. diff --git a/.changeset/plugin-skill-qualified-names.md b/.changeset/plugin-skill-qualified-names.md new file mode 100644 index 000000000..efe23f1fe --- /dev/null +++ b/.changeset/plugin-skill-qualified-names.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Register plugin skills as `:`; a bare skill name no longer runs a plugin skill and there is no setting to restore that, so use `/skill::`. Plugin names can now contain uppercase letters, dots, and an `@` suffix. diff --git a/.changeset/registry-reimport-default.md b/.changeset/registry-reimport-default.md new file mode 100644 index 000000000..129d1eaaa --- /dev/null +++ b/.changeset/registry-reimport-default.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Keep the default model and provider when re-importing a provider registry. diff --git a/.changeset/relay-offline-exit.md b/.changeset/relay-offline-exit.md new file mode 100644 index 000000000..f6a61cfe8 --- /dev/null +++ b/.changeset/relay-offline-exit.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Exit `pythinker web` when the Remote Control relay takes the device offline. diff --git a/.changeset/remote-control-large-transfers.md b/.changeset/remote-control-large-transfers.md new file mode 100644 index 000000000..b59026563 --- /dev/null +++ b/.changeset/remote-control-large-transfers.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Remote Control now sends HTTP responses larger than 10 MiB in 5 MiB chunks, up to about 100 MiB, and cancels a transfer when the relay aborts it. diff --git a/.changeset/remove-mcp-pyaos-executor.md b/.changeset/remove-mcp-pyaos-executor.md new file mode 100644 index 000000000..3b7d16484 --- /dev/null +++ b/.changeset/remove-mcp-pyaos-executor.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Remove the never-implemented `executor = "pyaos"` option (and its old `"kaos"` alias) from MCP stdio server configuration; configs that use it now fail validation at load time. diff --git a/.changeset/select-tools-errors.md b/.changeset/select-tools-errors.md new file mode 100644 index 000000000..100ae4564 --- /dev/null +++ b/.changeset/select-tools-errors.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Explain why select_tools cannot load a tool, and report tools that are not available in the current session separately. diff --git a/.changeset/shutdown-progress.md b/.changeset/shutdown-progress.md new file mode 100644 index 000000000..f515b1f13 --- /dev/null +++ b/.changeset/shutdown-progress.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Show shutdown progress while shutdown hooks run, and exit at once when you press Ctrl+C again. diff --git a/.changeset/subagent-tool-descriptions.md b/.changeset/subagent-tool-descriptions.md new file mode 100644 index 000000000..35967e7d7 --- /dev/null +++ b/.changeset/subagent-tool-descriptions.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Describe only the tools a subagent can actually use. diff --git a/.changeset/task-notification-answer.md b/.changeset/task-notification-answer.md new file mode 100644 index 000000000..06d9b733b --- /dev/null +++ b/.changeset/task-notification-answer.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Keep the previous answer visible when a background task notification turn ends, and show cancelled background agents as stopped. diff --git a/.changeset/todo-list-nudge.md b/.changeset/todo-list-nudge.md new file mode 100644 index 000000000..57dad8a19 --- /dev/null +++ b/.changeset/todo-list-nudge.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Remind the agent to update its todo list when items stay open for several steps. diff --git a/.changeset/tower-deterministic-missions.md b/.changeset/tower-deterministic-missions.md new file mode 100644 index 000000000..e2cd1eaa6 --- /dev/null +++ b/.changeset/tower-deterministic-missions.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Tower now merges and reviews missions through deterministic git checks, adds the TowerComplete and TowerRebase tools, and blocks destructive shell commands in the main checkout. Enable Tower with PYTHINKER_CODE_EXPERIMENTAL_TOWER=1. diff --git a/.changeset/vscode-question-back.md b/.changeset/vscode-question-back.md new file mode 100644 index 000000000..5cd74c810 --- /dev/null +++ b/.changeset/vscode-question-back.md @@ -0,0 +1,5 @@ +--- +"pythinker": patch +--- + +Add Back navigation to multi-question prompts and keep earlier answers while moving between questions. diff --git a/.changeset/working-spinner-label.md b/.changeset/working-spinner-label.md new file mode 100644 index 000000000..27967168f --- /dev/null +++ b/.changeset/working-spinner-label.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Show Working… instead of Thinking… in the status spinner while the model thinks. diff --git a/AGENTS.md b/AGENTS.md index a291c245b..4fd79aec1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,6 @@ Adding an OpenAI-compatible provider requires **zero code changes** — just add | `packages/agent-core-v2` | DI × Scope agent engine (the v2 port behind agent-gateway) | Three `LifecycleScope` tiers — `App` / `Session` / `Agent` (`app/scopes.ts`). Workspace resources use App-owned `WorkspaceInstance` / `Program` lifetimes, not a DI scope. Also includes the L3 unit layer (`Service`/`Fiber` units, collection contribution points, the Feature seam in `src/features/`). See its `AGENTS.md` and use the `agent-core-dev` skill. | | `packages/node-sdk` | Public TS SDK & harness | | | `packages/kosong` | LLM provider abstraction | Wire types, catalog, capability registry. | -| `packages/pyaos` | Execution environment | File/process abstractions. | | `packages/agent-gateway` | Pythinker Code server | Fastify server backed by `@pymodel/agent-core-v2`; sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`), plus `/api/v1/debug/*` reflection RPC (`--debug-endpoints`, loopback bind + bearer auth). See its `AGENTS.md`. | | `packages/klient` | Client SDK | Contract-driven facade over agent-core-v2 (`global.*` / `session(id).*` / `agent(id).*`, zod-validated); transport via subpath entry (`@pymodel/klient/ipc|memory`); hosts the e2e suites. See its `AGENTS.md`. | | `packages/transcript` | Isomorphic transcript rendering data layer | L1 agent-granular store, L2 idempotent operations, L3 `off/turn/block/delta` subscription granularity, L4 framework-free view registry, turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports); sole owner of the transcript contract types (`src/contract/`). See its `AGENTS.md`. | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfdbd3e3a..960df693e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ This is a pnpm monorepo. The most relevant entry points are: - `apps/vis` — session debug visualizer - `packages/node-sdk` — public TypeScript SDK (`@pymodel/pythinker-code-sdk`) - `packages/agent-core-v2` — the agent engine (v2, DI Scope architecture); `packages/agent-core` is v1 and being phased out -- `packages/klient`, `agent-gateway`, `protocol`, `transcript`, `kosong`, `pyaos`, `oauth`, `telemetry` — internal engine packages +- `packages/klient`, `agent-gateway`, `protocol`, `transcript`, `kosong`, `oauth`, `telemetry` — internal engine packages - `docs/` — VitePress bilingual docs site For the full project map, see [AGENTS.md](AGENTS.md). diff --git a/README.md b/README.md index e586616a1..2abfbc94e 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,6 @@ Pythinker Code is a pnpm monorepo. The desktop app and the CLI both talk to the | `apps/pythinker-web` | Browser interface that the desktop app renders | | `packages/agent-core` | Agent engine: sessions, tools, skills, permissions, plans | | `packages/kosong` | LLM and provider abstraction | -| `packages/pyaos` | Execution environment, file and process abstractions | | `packages/server` | REST and WebSocket session host (`/api/v1`) | | `packages/node-sdk` | Public TypeScript SDK | diff --git a/apps/pythinker-code/dist-web/.web-bundle-manifest.json b/apps/pythinker-code/dist-web/.web-bundle-manifest.json index f2448a428..36a6af4e7 100644 --- a/apps/pythinker-code/dist-web/.web-bundle-manifest.json +++ b/apps/pythinker-code/dist-web/.web-bundle-manifest.json @@ -1,4 +1,4 @@ { - "sourceHash": "b03226b74b69912ccace38e0a803ee98077478208348852dc4857429e06abc33", + "sourceHash": "85ca51758dad0358848603e07154c341133ae7d7f92aca71ecc47039255b4072", "sourceFileCount": 497 } diff --git a/apps/pythinker-code/package.json b/apps/pythinker-code/package.json index 4f98d9c60..5eeefbe06 100644 --- a/apps/pythinker-code/package.json +++ b/apps/pythinker-code/package.json @@ -116,6 +116,7 @@ "node": ">=22.19.0" }, "dependencies": { + "chokidar": "^4.0.3", "qrcode": "^1.5.4", "ws": "^8.21.3" } diff --git a/apps/pythinker-code/src/cli/sub/provider.ts b/apps/pythinker-code/src/cli/sub/provider.ts index 381ec4a14..0e3db3f58 100644 --- a/apps/pythinker-code/src/cli/sub/provider.ts +++ b/apps/pythinker-code/src/cli/sub/provider.ts @@ -6,10 +6,10 @@ * a provider, or inspect what is configured without launching the TUI. * * `add` writes the same `source = { kind: 'apiJson', url, apiKey }` blob the - * TUI does; the next launch's `refreshAllProviderModels` - * (apps/pythinker-code/src/tui/utils/refresh-providers.ts) groups by URL, retries - * available API-key candidates, and re-fetches the model list, so periodic - * refresh is automatic. + * TUI does; the next launch's provider-model refresh + * (`PythinkerHarness.refreshProviderModels`, backed by the engine's discovery + * service) groups by URL, retries available API-key candidates, and + * re-fetches the model list, so periodic refresh is automatic. */ import { diff --git a/apps/pythinker-code/src/cli/sub/web/remote-control.ts b/apps/pythinker-code/src/cli/sub/web/remote-control.ts index 240bbd9be..3874f677f 100644 --- a/apps/pythinker-code/src/cli/sub/web/remote-control.ts +++ b/apps/pythinker-code/src/cli/sub/web/remote-control.ts @@ -27,6 +27,7 @@ export { } from '@pymodel/remote-control'; export type { ParsedRawHttpRequest, + RemoteControlCloseReason, RemoteControlHandle, RemoteControlLock, RemoteControlLockInfo, @@ -88,6 +89,8 @@ export function formatRemoteControlStatus(status: RemoteControlStatus): string { return ` ${value('✓')} ${label('Connected to relay, waiting for remote devices…')}\n`; case 'relay_disconnected': return ` ${value('!')} ${label('Relay disconnected; reconnecting…')}\n`; + case 'relay_terminated': + return ` ${value('✕')} ${label('Taken offline from the web; exiting…')}\n`; case 'device_connected': return ` ${value('✓')} ${label('Remote device connected (1 active session)')}\n`; case 'device_disconnected': diff --git a/apps/pythinker-code/src/cli/sub/web/run.ts b/apps/pythinker-code/src/cli/sub/web/run.ts index 3532e6bfd..a1f6a81b8 100644 --- a/apps/pythinker-code/src/cli/sub/web/run.ts +++ b/apps/pythinker-code/src/cli/sub/web/run.ts @@ -81,9 +81,14 @@ export interface WebCliOptions extends ServerCliOptions { remoteControl?: boolean; } +export interface ForegroundControls { + /** Ask the foreground runner to shut the server down and exit (like SIGINT). */ + requestShutdown(reason: string): void; +} + export interface StartForegroundHooks { /** Fires once the server is listening, before the foreground runner blocks. */ - onReady?: (origin: string) => void | Promise; + onReady?: (origin: string, controls?: ForegroundControls) => void | Promise; onShutdown?: (reason: string) => void | Promise; } @@ -207,7 +212,7 @@ export async function handleWebCommand( const run = deps.startServerForeground ?? startServerForeground; let remoteControl: RemoteControlHandle | undefined; await run(parsed, { - onReady: async (origin) => { + onReady: async (origin, controls) => { // Resolve the persistent token only once the server is up: a fresh // server writes `server.token` on first boot, so reading it beforehand // would miss first-time starts and the browser would hit the auth gate. @@ -238,6 +243,19 @@ export async function handleWebCommand( stderr: deps.stderr, onStatus, }); + void remoteControl.closed.then( + (reason) => { + if (reason === 'user_requested') { + controls?.requestShutdown('remote_control_user_requested'); + } + }, + (error) => { + deps.stderr.write( + `Remote Control lock release failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + controls?.requestShutdown('remote_control_closed'); + }, + ); const qrCode = await generateRemoteControlQr(remoteControl.url, dataDir); deps.stdout.write( formatRemoteControlOutput({ @@ -410,7 +428,9 @@ async function runServerInProcess( running.logger.info({ address: running.address }, 'server ready'); try { - await hooks.onReady?.(running.address); + await hooks.onReady?.(running.address, { + requestShutdown: (reason) => void shutdown(reason), + }); } catch (error) { try { await hooks.onShutdown?.('startup_failed'); diff --git a/apps/pythinker-code/src/constant/app.ts b/apps/pythinker-code/src/constant/app.ts index 500ac38d9..495941221 100644 --- a/apps/pythinker-code/src/constant/app.ts +++ b/apps/pythinker-code/src/constant/app.ts @@ -69,6 +69,7 @@ export const PYTHINKER_CODE_BANNER_DIR_NAME = 'banner'; export const PYTHINKER_CODE_BANNER_STATE_FILE_NAME = 'state.json'; export const PYTHINKER_CODE_SURVEY_STATE_FILE_NAME = 'feedback-survey-state.json'; export const PYTHINKER_CODE_RECOMMENDED_EFFORT_STATE_FILE_NAME = 'recommended-effort-state.json'; +export const PYTHINKER_CODE_MODEL_MIGRATION_STATE_FILE_NAME = 'model-migrations.json'; // Managed Pythinker auth provider key shared with OAuth/SDK config. export const DEFAULT_OAUTH_PROVIDER_NAME = 'openai'; diff --git a/apps/pythinker-code/src/tui/commands/config.ts b/apps/pythinker-code/src/tui/commands/config.ts index c8525064e..fb5ad11b8 100644 --- a/apps/pythinker-code/src/tui/commands/config.ts +++ b/apps/pythinker-code/src/tui/commands/config.ts @@ -1,12 +1,10 @@ import { - effectiveModelAlias, PRIMARY_SUBAGENT_MODEL_CHOICE, SECONDARY_DERIVED_MODEL_ALIAS, type ExperimentalFeatureState, type ModelAlias, type PermissionMode, type Session, - type ThinkingEffort, } from '@pymodel/pythinker-code-sdk'; import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; @@ -30,8 +28,13 @@ import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from import { NO_ACTIVE_SESSION_MESSAGE, TUI_MODE_RESTART_NOTICE } from '../constant/pythinker-tui'; import { formatErrorMessage } from '../utils/event-payload'; import { setMarkdownMermaidMode, type MermaidRenderMode } from '../utils/markdown-options'; +import { + effectiveModelForHost, + hasConversationHistory, + MODEL_SWITCH_CACHE_WARNING, + performModelSwitch, +} from '../utils/model-switch'; import { PERMISSION_MODE_DESCRIPTIONS, PERMISSION_MODE_DISPLAY_NAMES } from '../utils/permission-mode'; -import { thinkingEffortToConfig } from '../utils/thinking-config'; import { showUsage } from './info'; import { setExperimentalFeatures } from './experimental-flags'; import type { SlashCommandHost } from './dispatch'; @@ -42,21 +45,9 @@ import type { SlashCommandHost } from './dispatch'; const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000; -const MODEL_SWITCH_CACHE_WARNING = - 'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.'; const EFFORT_SWITCH_CACHE_WARNING = 'Note: Switching effort invalidates the existing prompt cache. Use /new to avoid extra token costs.'; -/** True once the conversation has at least one user message: a switch from - * then on resends the accumulated context, losing the cache. Shell-command - * echoes are also 'user' transcript entries but carry an empty `bullet`, so - * they're excluded. */ -function hasConversationHistory(host: SlashCommandHost): boolean { - return host.state.transcriptEntries.some( - (entry) => entry.kind === 'user' && entry.bullet !== '', - ); -} - export function currentTuiConfig(host: Pick): TuiConfig { return { theme: host.state.appState.theme, @@ -74,14 +65,6 @@ export function currentTuiConfig(host: Pick): TuiConf }; } -export function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias { - const providerType = host.state.appState.availableProviders[model.provider]?.type; - // Flat models (no named provider, e.g. inline base_url served by a v2 - // backend) have no provider entry to look up; their own protocol declaration - // plays the provider-identity role, mirroring the resolver. - return effectiveModelAlias(model, providerType ?? model.protocol); -} - export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise { const session = host.session; if (session === undefined) { @@ -243,7 +226,7 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): 'warning', ); } - await performModelSwitch(host, alias, arg, true); + await performModelSwitch(host, alias, arg, 'model'); } function showEffortPicker( @@ -261,11 +244,11 @@ function showEffortPicker( warning: hasConversationHistory(host) ? EFFORT_SWITCH_CACHE_WARNING : undefined, onSelect: (effort) => { host.restoreEditor(); - void performModelSwitch(host, alias, effort, true); + void performModelSwitch(host, alias, effort, 'model'); }, onSessionOnlySelect: (effort) => { host.restoreEditor(); - void performModelSwitch(host, alias, effort, false); + void performModelSwitch(host, alias, effort, 'session'); }, onCancel: () => { host.restoreEditor(); @@ -388,11 +371,11 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string = warning: hasConversationHistory(host) ? MODEL_SWITCH_CACHE_WARNING : undefined, onSelect: ({ alias, thinking }) => { host.restoreEditor(); - void performModelSwitch(host, alias, thinking, true); + void performModelSwitch(host, alias, thinking, 'model'); }, onSessionOnlySelect: ({ alias, thinking }) => { host.restoreEditor(); - void performModelSwitch(host, alias, thinking, false); + void performModelSwitch(host, alias, thinking, 'session'); }, onCancel: () => { host.restoreEditor(); @@ -417,142 +400,7 @@ export async function cycleThinkingEffort(host: SlashCommandHost): Promise if (cycle.length < 2) return; const index = cycle.indexOf(host.state.appState.thinkingEffort); const next = cycle[(index + 1) % cycle.length]!; - await performModelSwitch(host, alias, next, false, true); -} - -async function performModelSwitch( - host: SlashCommandHost, - alias: string, - effort: ThinkingEffort, - persist: boolean, - quiet = false, -): Promise { - let session = host.session; - if (session === undefined) { - // A first prompt may still be inside lazy creation: wait it out so the - // switch lands on the new session instead of being overwritten by its - // assembly. - await host.waitForLazyCreation(); - session = host.session; - } - if (host.state.appState.streamingPhase !== 'idle') { - host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); - return; - } - - const prevModel = host.state.appState.model; - const prevEffort = host.state.appState.thinkingEffort; - const modelChanged = alias !== prevModel; - const effortChanged = effort !== prevEffort; - const runtimeChanged = modelChanged || effortChanged; - let effectiveAlias = alias; - let effectiveEffort = effort; - - try { - if (session === undefined && runtimeChanged) { - await host.authFlow.activateModelAfterLogin(alias, effort); - } else if (session !== undefined) { - if (alias !== prevModel) { - await session.setModel(alias); - } - if (effort !== prevEffort) { - await session.setThinking(effort); - } - const status = await session.getStatus(); - effectiveAlias = status.model ?? alias; - effectiveEffort = status.thinkingEffort; - } - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Failed to switch model: ${msg}`); - return; - } - - if (session === undefined) { - effectiveAlias = host.state.appState.model; - effectiveEffort = host.state.appState.thinkingEffort; - } - const effectiveModelChanged = effectiveAlias !== prevModel; - const effectiveEffortChanged = effectiveEffort !== prevEffort; - const displayName = modelDisplayName( - effectiveAlias, - host.state.appState.availableModels[effectiveAlias], - ); - host.setAppState({ model: effectiveAlias, thinkingEffort: effectiveEffort }); - if (session === undefined && runtimeChanged) { - if (effectiveModelChanged) { - host.track('model_switch', { model: effectiveAlias }); - } - if (effectiveEffortChanged) { - host.track('thinking_toggle', { - enabled: effectiveEffort !== 'off', - effort: effectiveEffort, - from: prevEffort, - }); - } - } - - let persisted = false; - if (persist) { - try { - persisted = await persistModelSelection( - host, - effectiveAlias, - effectiveEffort, - effectiveEffortChanged, - ); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`); - return; - } - } - - if (quiet) return; - let status: string; - if (effectiveModelChanged) { - status = persist - ? `Switched to ${displayName} with thinking ${effectiveEffort}.` - : `Switched to ${displayName} with thinking ${effectiveEffort} for this session only.`; - } else if (effectiveEffortChanged) { - status = persist - ? `Thinking set to ${effectiveEffort}.` - : `Thinking set to ${effectiveEffort} for this session only.`; - } else if (persist && persisted) { - status = `Saved ${displayName} with thinking ${effectiveEffort} as default.`; - } else { - status = `Already using ${displayName} with thinking ${effectiveEffort}.`; - } - host.showStatus(status, 'success'); -} - -async function persistModelSelection( - host: SlashCommandHost, - alias: string, - effort: ThinkingEffort, - effortChanged: boolean, -): Promise { - const config = await host.harness.getConfig({ reload: true }); - const model = host.state.appState.availableModels[alias]; - const full = thinkingEffortToConfig( - effort, - model === undefined ? undefined : effectiveModelForHost(host, model), - ); - // Re-confirming the effort shown when the picker opened is not an explicit - // choice — persist the model but leave the stored effort preference alone. - const patch = effortChanged ? full : { enabled: full.enabled }; - if ( - config.defaultModel === alias && - config.thinking?.enabled === patch.enabled && - (!effortChanged || config.thinking?.effort === patch.effort) - ) { - return false; - } - await host.harness.setConfig({ - defaultModel: alias, - thinking: patch, - }); - return true; + await performModelSwitch(host, alias, next, 'session', true); } // --------------------------------------------------------------------------- diff --git a/apps/pythinker-code/src/tui/commands/dispatch.ts b/apps/pythinker-code/src/tui/commands/dispatch.ts index 6ec6adc63..f63f9b3bb 100644 --- a/apps/pythinker-code/src/tui/commands/dispatch.ts +++ b/apps/pythinker-code/src/tui/commands/dispatch.ts @@ -20,10 +20,6 @@ import type { TranscriptEntry, } from '../types'; import { formatErrorMessage } from '../utils/event-payload'; -import { - extractInlineSkillActivations, - findInlineSkillTokens, -} from '../utils/inline-skill-tokens'; import { handleLoginCommand, handleLogoutCommand } from './auth'; import { handleBtwCommand } from './btw'; import { handleCopyCommand } from './copy'; @@ -56,7 +52,8 @@ import { handleReloadCommand, handleReloadTuiCommand } from './reload'; import type { SkillListSession } from './skills'; import { canRestoreSubmittedInput, - resolveSlashCommandInput, + resolveUserInput, + type SlashCommandIntent, slashBusyMessage, slashCommandBusyReason, } from './resolve'; @@ -129,7 +126,7 @@ export interface SlashCommandHost { showNotice(title: string, detail?: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; track(event: string, props?: Record): void; - mountEditorReplacement(panel: Component & Focusable): void; + mountEditorReplacement(panel: Component & Focusable, onDisplaced?: () => void): void; restoreEditor(): void; restoreInputText(text: string): void; refreshSlashCommandAutocomplete(): void; @@ -160,8 +157,10 @@ export interface SlashCommandHost { * fails. */ ensureSession(): Promise; - /** Await the in-flight lazy session creation, if any (v2); no-op otherwise. */ - waitForLazyCreation(): Promise; + /** Await in-flight session assembly — lazy creation or a `/new` replacement — if any; no-op otherwise. */ + waitForSessionAssembly(): Promise; + /** True while a `/new` replacement is assembling; idle checks must treat it as busy. */ + readonly sessionReplacementInFlight: boolean; switchToSession(session: Session, message: string): Promise; reloadCurrentSessionView(session: Session, message: string): Promise; beginSessionRequest(): void; @@ -201,7 +200,6 @@ export interface SlashCommandHost { * and launch as a single turn. */ sendInlineSkillUserInput(text: string, activations: readonly InlineSkillActivation[]): Promise; - sendSkillActivation(session: Session, skillName: string, skillArgs: string): void; activatePluginCommand( session: Session, pluginId: string, @@ -223,92 +221,40 @@ export interface SlashCommandHost { // --------------------------------------------------------------------------- export function dispatchInput(host: SlashCommandHost, text: string): void { - if (parseSlashInput(text) !== null) { - // A leading skill command combined with further inline skill tokens - // (`/skill:a args /skill:b`) is one grouped submission on the v2 engine. - if (dispatchInlineSkillCombo(host, text)) { - return; - } - void executeSlashCommand(host, text); - return; - } - // Inline skill tokens anywhere in a plain prompt activate the skills. - const activations = extractInlineSkillActivations(text, host.skillCommandMap); - if (activations.length > 0) { - void host.sendInlineSkillUserInput(text, activations); - return; - } - host.sendNormalUserInput(text); -} - -/** - * Handle a leading-slash input that may be a bundled submission. Returns true - * when the input was claimed, false when it should fall through to the - * regular single-skill slash path. - * - * Bundle rule: two or more known skill tokens with the first one leading the - * input make the whole input one bundled prompt in which every token - * activates with NO args — the mention is the whole interface, and args stay - * a standalone-activation concept (`/skill:a some args` with no other tokens - * keeps its single-skill path). Tokenization is whitespace-generic, so - * space- and newline-separated bundles behave identically. A recognized - * builtin or plugin command always keeps its own path, no matter how many - * skill tokens its arguments mention. - */ -function dispatchInlineSkillCombo(host: SlashCommandHost, text: string): boolean { - // The intent is parsed without the busy flags on purpose: submissions - // through sendInlineSkillUserInput queue while busy — only genuine - // single-skill commands reject. - const intent = resolveSlashCommandInput({ + const { intent, activations } = resolveUserInput({ input: text, skillCommandMap: host.skillCommandMap, pluginCommandMap: host.pluginCommandMap, - isStreaming: false, - isCompacting: false, + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, engineV2: host.engineV2 === true, }); - if (intent.kind !== 'skill' && intent.kind !== 'message') return false; - - const tokens = findInlineSkillTokens(text, { - isKnownSkill: (commandName) => - host.skillCommandMap.has(commandName) || host.skillCommandMap.has(`skill:${commandName}`), - includeLeading: true, - }); - // The 'message' kind joins the bundle rule because parseSlashInput only - // splits on a literal space: a newline after a leading skill resolves to - // 'message' instead of 'skill', and must not silently drop the leading - // activation. - if (tokens.length >= 2 && tokens[0]!.start === 0) { - const activations = extractInlineSkillActivations(text, host.skillCommandMap, { - includeLeading: true, - }); + if (intent.kind === 'skill' || activations.length > 0) { + const leading = activations[0]; + if (intent.kind === 'skill' && leading?.args !== undefined && host.state.appState.model.trim().length > 0) { + host.track('input_command', { + command: intent.commandName, + skill_name: leading.skillName, + }); + } void host.sendInlineSkillUserInput(text, activations); - return true; + return; } - - // An unrecognized leading slash token makes the whole input a plain - // message; scan it for inline skills like any other plain prompt. - if (intent.kind !== 'message') return false; - const activations = extractInlineSkillActivations(text, host.skillCommandMap); - if (activations.length === 0) return false; - void host.sendInlineSkillUserInput(text, activations); - return true; + if (intent.kind !== 'not-command') { + void executeSlashCommand(host, text, intent); + return; + } + host.sendNormalUserInput(text); } -async function executeSlashCommand(host: SlashCommandHost, input: string): Promise { +async function executeSlashCommand( + host: SlashCommandHost, + input: string, + intent: Exclude, +): Promise { const parsedCommand = parseSlashInput(input); - const intent = resolveSlashCommandInput({ - input, - skillCommandMap: host.skillCommandMap, - pluginCommandMap: host.pluginCommandMap, - isStreaming: host.state.appState.streamingPhase !== 'idle', - isCompacting: host.state.appState.isCompacting, - engineV2: host.engineV2 === true, - }); switch (intent.kind) { - case 'not-command': - return; case 'blocked': host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName }); host.showError(slashBusyMessage(intent.commandName, intent.reason)); @@ -323,35 +269,6 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi }); host.showError(`Invalid slash command: /${intent.commandName}`); return; - case 'skill': { - if (host.state.appState.model.trim().length === 0) { - host.showError(LLM_NOT_SET_MESSAGE); - return; - } - let session = host.session; - if (session === undefined) { - session = await ensureSessionForCommand(host); - if (session === undefined) return; - // A first prompt may have started a turn while the session was being - // created; skill commands are always busy-gated, so re-check the gate - // resolved before the await. - const busyReason = slashCommandBusyReason({ - isStreaming: host.state.appState.streamingPhase !== 'idle', - isCompacting: host.state.appState.isCompacting, - engineV2: host.engineV2 === true, - }); - if (busyReason !== undefined) { - host.showError(slashBusyMessage(intent.commandName, busyReason)); - return; - } - } - host.track('input_command', { - command: intent.commandName, - skill_name: intent.skillName, - }); - host.sendSkillActivation(session, intent.skillName, intent.args); - return; - } case 'plugin-command': { if (host.state.appState.model.trim().length === 0) { host.showError(LLM_NOT_SET_MESSAGE); @@ -361,8 +278,6 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi if (session === undefined) { session = await ensureSessionForCommand(host); if (session === undefined) return; - // Same busy re-check as the skill path: plugin commands are always - // busy-gated too. const busyReason = slashCommandBusyReason({ isStreaming: host.state.appState.streamingPhase !== 'idle', isCompacting: host.state.appState.isCompacting, @@ -472,7 +387,7 @@ async function handleBuiltInSlashCommand( case 'new': { // A first-use lazy creation may still be in flight: wait it out so /new // never races a second createSession against the pending prompt. - await host.waitForLazyCreation(); + await host.waitForSessionAssembly(); // The waited-out prompt may have started a turn meanwhile; /new is // idle-only, so re-run the busy gate resolved before the await. const busyReason = slashCommandBusyReason({ diff --git a/apps/pythinker-code/src/tui/commands/provider.ts b/apps/pythinker-code/src/tui/commands/provider.ts index 349e5f977..99fe44354 100644 --- a/apps/pythinker-code/src/tui/commands/provider.ts +++ b/apps/pythinker-code/src/tui/commands/provider.ts @@ -31,7 +31,7 @@ import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/pythinker-tui'; import { formatErrorMessage } from '../utils/event-payload'; import { thinkingEffortToConfig } from '../utils/thinking-config'; -import { effectiveModelForHost } from './config'; +import { effectiveModelForHost } from '../utils/model-switch'; import { promptApiKey, promptBaseUrl, @@ -310,7 +310,7 @@ export async function setDefaultModel( // A first prompt may still be inside lazy creation: wait it out so the // pick lands on the new session instead of racing its assembly (same // coordination as the /model path). - await host.waitForLazyCreation(); + await host.waitForSessionAssembly(); } await host.harness.setConfig({ defaultModel: alias, diff --git a/apps/pythinker-code/src/tui/commands/resolve.ts b/apps/pythinker-code/src/tui/commands/resolve.ts index e9866c632..6989ff392 100644 --- a/apps/pythinker-code/src/tui/commands/resolve.ts +++ b/apps/pythinker-code/src/tui/commands/resolve.ts @@ -7,8 +7,11 @@ import { import { isExperimentalFlagEnabled } from './experimental-flags'; import { parseSlashInput } from './parse'; import type { TUIState } from '../tui-state'; +import type { InlineSkillActivation } from '../types'; +import { extractInlineSkillActivations, findInlineSkillTokens } from '../utils/inline-skill-tokens'; import type { PythinkerSlashCommand, + ParsedSlashInput, SlashCommandBusyReason, SlashCommandInvalidReason, } from './types'; @@ -84,20 +87,19 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla }; } - const skillName = resolveSkillCommand(options.skillCommandMap, parsed.name); - if (skillName !== undefined) { - // Skill activations are never blocked by a busy session: the TUI queues - // them behind the running turn exactly like normal messages (see - // sendSkillActivation), and Ctrl-S steers them as real activations, so - // skill commands can be issued any time. - return { - kind: 'skill', - commandName: parsed.name, - skillName, - args: parsed.args.trim(), - }; + // Skill activations are never blocked by a busy session: the TUI queues + // them behind the running turn exactly like normal messages (see + // sendSkillActivation), and Ctrl-S steers them as real activations, so + // skill commands can be issued any time. + const exactSkillName = options.skillCommandMap.get(parsed.name); + if (exactSkillName !== undefined) { + return skillCommandIntent(parsed, exactSkillName); } + // A plugin command wins over the skill shorthand for the same + // `:` input: the skill stays reachable through its explicit + // `skill::` command, while the plugin command has no + // alternative form. if (options.pluginCommandMap.has(parsed.name)) { const busyReason = slashCommandBusyReason(options); if (busyReason !== undefined) { @@ -118,12 +120,45 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla }; } + const shorthandSkillName = options.skillCommandMap.get(`skill:${parsed.name}`); + if (shorthandSkillName !== undefined) { + return skillCommandIntent(parsed, shorthandSkillName); + } + return { kind: 'message', input: options.input, }; } +export function resolveUserInput(options: ResolveSlashCommandInput): { + readonly intent: SlashCommandIntent; + readonly activations: readonly InlineSkillActivation[]; +} { + const intent = resolveSlashCommandInput(options); + if (intent.kind !== 'skill' && intent.kind !== 'message' && intent.kind !== 'not-command') { + return { intent, activations: [] }; + } + const text = options.input.trim(); + const activations = extractInlineSkillActivations(text, options.skillCommandMap, { + includeLeading: true, + }); + if (activations.length === 1) { + const tokens = findInlineSkillTokens(text, { + isKnownSkill: (name) => resolveSkillCommand(options.skillCommandMap, name) !== undefined, + includeLeading: true, + }); + if (tokens.length === 1 && tokens[0]?.start === 0) { + if (intent.kind !== 'skill') return { intent, activations: [] }; + return { + intent, + activations: [{ skillName: activations[0]!.skillName, args: text.slice(tokens[0].end).trim() }], + }; + } + } + return { intent, activations }; +} + export function resolveSkillCommand( skillCommandMap: ReadonlyMap, commandName: string, @@ -131,6 +166,15 @@ export function resolveSkillCommand( return skillCommandMap.get(commandName) ?? skillCommandMap.get(`skill:${commandName}`); } +function skillCommandIntent(parsed: ParsedSlashInput, skillName: string): SlashCommandIntent { + return { + kind: 'skill', + commandName: parsed.name, + skillName, + args: parsed.args.trim(), + }; +} + export function slashCommandBusyReason( options: Partial, ): SlashCommandBusyReason | undefined { diff --git a/apps/pythinker-code/src/tui/commands/tower.ts b/apps/pythinker-code/src/tui/commands/tower.ts index e571b72d9..003a0f6bd 100644 --- a/apps/pythinker-code/src/tui/commands/tower.ts +++ b/apps/pythinker-code/src/tui/commands/tower.ts @@ -1,6 +1,6 @@ import type { Session } from '@pymodel/pythinker-code-sdk'; -import { TOWER_STATUS_PROMPT, TOWER_TEARDOWN_PROMPT } from '../constant/pythinker-tui'; +import { TOWER_TEARDOWN_PROMPT } from '../constant/pythinker-tui'; import { formatErrorMessage } from '../utils/event-payload'; import type { SlashCommandHost } from './dispatch'; @@ -17,7 +17,16 @@ export async function handleTowerCommand(host: SlashCommandHost, args: string): return; } if (sub === '' || sub === 'status') { - host.sendNormalUserInput(TOWER_STATUS_PROMPT); + const session = await requireSessionEnsured(host); + if (session === undefined) return; + try { + // The SDK returns the engine-rendered summary; its first line is the + // state headline, which the notice lifts into the title. + const [title, ...rest] = (await session.getTowerStatus()).split('\n'); + host.showNotice(title ?? 'Tower status', rest.length > 0 ? rest.join('\n') : undefined); + } catch (error) { + host.showError(`Failed to read tower status: ${formatErrorMessage(error)}`); + } return; } if (sub === 'teardown') { @@ -84,7 +93,7 @@ async function setTowerMode( async function requireSessionEnsured(host: SlashCommandHost): Promise { if (host.session !== undefined) return host.session; - // v2 session-less: lazy-create the session, then toggle — the same path + // v2 session-less: lazy-create the session on first use — the same path // the first prompt takes. return host.ensureSession(); } diff --git a/apps/pythinker-code/src/tui/commands/web.ts b/apps/pythinker-code/src/tui/commands/web.ts index b0c9cb411..fdc697bba 100644 --- a/apps/pythinker-code/src/tui/commands/web.ts +++ b/apps/pythinker-code/src/tui/commands/web.ts @@ -44,7 +44,7 @@ export async function handleWebCommand(host: SlashCommandHost): Promise { } export async function handleRemoteControlCommand(host: SlashCommandHost): Promise { - await host.waitForLazyCreation(); + await host.waitForSessionAssembly(); const session = host.session; const holder = await inspectRemoteControlLock(getDataDir()); @@ -67,7 +67,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis try { const relayOrigin = resolveRelayOrigin(); await startServerForeground(options, { - onReady: async (origin) => { + onReady: async (origin, controls) => { const dataDir = getDataDir(); const token = tryResolveServerToken(dataDir); if (token === undefined) throw new Error('Unable to read the local server token.'); @@ -87,6 +87,19 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis relayOrigin, onStatus, }); + void remoteControl.closed.then( + (reason) => { + if (reason === 'user_requested') { + controls?.requestShutdown('remote_control_user_requested'); + } + }, + (error) => { + process.stderr.write( + `Remote Control lock release failed: ${formatErrorMessage(error)}\n`, + ); + controls?.requestShutdown('remote_control_closed'); + }, + ); const url = buildRemoteControlUrl(remoteControl.deviceId, session?.id, relayOrigin); const qrCode = await generateRemoteControlQr(url, dataDir); process.stdout.write( diff --git a/apps/pythinker-code/src/tui/components/chrome/footer.ts b/apps/pythinker-code/src/tui/components/chrome/footer.ts index 160e174a5..3025d7a41 100644 --- a/apps/pythinker-code/src/tui/components/chrome/footer.ts +++ b/apps/pythinker-code/src/tui/components/chrome/footer.ts @@ -228,6 +228,7 @@ export class FooterComponent implements Component { setState(state: AppState): void { if (state.workDir !== this.gitCacheWorkDir) { + this.gitCache.dispose(); this.gitCacheWorkDir = state.workDir; this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh, @@ -299,6 +300,10 @@ export class FooterComponent implements Component { this.backgroundAgentCount = Math.max(0, counts.agentTasks); } + refreshGitStatus(): void { + this.gitCache.refresh(); + } + invalidate(): void {} render(width: number): string[] { @@ -543,6 +548,7 @@ export class FooterComponent implements Component { } dispose(): void { + this.gitCache.dispose(); if (this.goalTimer !== null) { clearInterval(this.goalTimer); this.goalTimer = null; diff --git a/apps/pythinker-code/src/tui/components/chrome/notify-panel.ts b/apps/pythinker-code/src/tui/components/chrome/notify-panel.ts index c71884ad9..f4415c72d 100644 --- a/apps/pythinker-code/src/tui/components/chrome/notify-panel.ts +++ b/apps/pythinker-code/src/tui/components/chrome/notify-panel.ts @@ -14,7 +14,7 @@ */ import type { Component } from '@pymodel/pi-tui'; -import { truncateToWidth, visibleWidth } from '@pymodel/pi-tui'; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@pymodel/pi-tui'; import chalk from 'chalk'; import { Markdown } from '#/tui/components/markdown/markdown'; @@ -31,6 +31,8 @@ export interface NotifyEntry { readonly agentId: string; readonly agentName?: string; readonly time: number; + /** One-line headline of the update; absent for updates posted without one. */ + readonly title?: string; readonly text: string; } @@ -214,14 +216,26 @@ export class NotifyPanelComponent implements Component { const innerWidth = Math.max(1, width - 6); const ch = this.activeChannel(); const entry = ch.entries[Math.min(ch.page, ch.entries.length - 1)]!; - const bodyRows = new Markdown( - entry.text.trim(), - 0, - 0, - createMarkdownTheme(), - undefined, - createMarkdownOptions(), - ).render(innerWidth); + const titleRows = + entry.title === undefined + ? [] + : [ + ...wrapTextWithAnsi(entry.title, innerWidth).map((row) => + chalk.hex(c.textStrong).bold(row), + ), + '', + ]; + const bodyRows = [ + ...titleRows, + ...new Markdown( + entry.text.trim(), + 0, + 0, + createMarkdownTheme(), + undefined, + createMarkdownOptions(), + ).render(innerWidth), + ]; const padRow = (row: string): string => ` ${padToVisibleWidth(row, width - 6)} `; const emptyRow = ' '.repeat(width); @@ -293,11 +307,12 @@ export class NotifyPanelComponent implements Component { return ` ${marker} ${dim(`${String(total)} ${noun} · ctrl+n`)}`; } - /** First non-empty line of the current entry, stripped of list/bold markers. */ + /** The current entry's title, else its first non-empty line stripped of list/bold markers. */ private stubPreviewText(): string | undefined { const ch = this.activeChannel(); const entry = ch.entries[Math.min(ch.page, ch.entries.length - 1)]; if (entry === undefined) return undefined; + if (entry.title !== undefined) return entry.title; const firstLine = entry.text .trim() .split('\n') diff --git a/apps/pythinker-code/src/tui/components/dialogs/choice-picker.ts b/apps/pythinker-code/src/tui/components/dialogs/choice-picker.ts index 7a26fd8cc..d86d9213d 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/choice-picker.ts @@ -18,6 +18,9 @@ import { } from '@pymodel/pi-tui'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme, type ColorToken } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; +import { Markdown } from '#/tui/components/markdown/markdown'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -39,14 +42,16 @@ export interface ChoicePickerOptions { readonly title: string; readonly hint?: string; readonly formatHint?: (text: string) => string; - readonly notice?: string; - /** Color tone for the notice line. Defaults to 'success'. */ + /** Plain text shown in `noticeTone`, or Markdown rendered with the Markdown theme. */ + readonly notice?: string | { readonly markdown: string }; + /** Color tone for a plain-text notice. Defaults to 'success'. */ readonly noticeTone?: 'success' | 'warning'; readonly options: readonly ChoiceOption[]; readonly currentValue?: string; readonly initialValue?: string; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; + readonly spaceSelect?: boolean; /** Items per page. Lists longer than this paginate. */ readonly pageSize?: number; readonly onSelect: (value: string) => void; @@ -119,10 +124,12 @@ export class ChoicePickerComponent extends Container implements Focusable { this.list.pageDown(); return; } - // Enter always selects. Space selects too — but only when the list is not - // searchable; in a searchable list a space must reach the query instead. + // Space confirmation is opt-out; searchable lists always route it to the query. const isSpace = matchesKey(data, Key.space) || printableChar(data) === ' '; - if (matchesKey(data, Key.enter) || (isSpace && this.opts.searchable !== true)) { + if ( + matchesKey(data, Key.enter) || + (isSpace && this.opts.searchable !== true && this.opts.spaceSelect !== false) + ) { const chosen = this.list.selected(); if (chosen !== undefined) this.opts.onSelect(chosen.value); return; @@ -157,10 +164,22 @@ export class ChoicePickerComponent extends Container implements Focusable { : this.opts.formatHint(` ${hintLine}`), ); } - if (this.opts.notice !== undefined) { + const notice = this.opts.notice; + if (typeof notice === 'object') { + const markdown = new Markdown( + notice.markdown, + 0, + 0, + createMarkdownTheme(), + undefined, + createMarkdownOptions(), + ); + lines.push(''); + lines.push(...markdown.render(Math.max(1, width - 1)).map((line) => ` ${line}`)); + } else if (notice !== undefined) { const tone = this.opts.noticeTone ?? 'success'; const noticeWidth = Math.max(1, width - 1); - for (const noticeLine of this.opts.notice.split(/\r?\n/)) { + for (const noticeLine of notice.split(/\r?\n/)) { for (const wrapped of wrapDescription(noticeLine, noticeWidth)) { lines.push(currentTheme.fg(tone, ` ${wrapped}`)); } diff --git a/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress.ts b/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress.ts index 410f772f3..8cec0d807 100644 --- a/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress.ts +++ b/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress.ts @@ -468,7 +468,7 @@ export class AgentDynamicWorkflowProgressComponent implements Component { markCancelled(agentId: string): void { const member = this.findMemberByAgentId(agentId); - if (member === undefined) return; + if (member === undefined || isTerminalPhase(member.phase)) return; this.cancelMember(member, Date.now()); this.markDirty(); } diff --git a/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts b/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts index 8813f6ae9..2928b58bc 100644 --- a/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts @@ -4,11 +4,15 @@ import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; -import type { BackgroundAgentStatusData } from '#/tui/types'; +import type { BackgroundAgentStatusData, BackgroundAgentStatusPhase } from '#/tui/types'; export class BackgroundAgentStatusComponent implements Component { constructor(private readonly data: BackgroundAgentStatusData) {} + get phase(): BackgroundAgentStatusPhase { + return this.data.phase; + } + invalidate(): void {} render(width: number): string[] { @@ -23,7 +27,9 @@ export class BackgroundAgentStatusComponent implements Component { : 'error'; const bullet = - this.data.phase === 'failed' ? currentTheme.fg(tone, FAILURE_MARK) : currentTheme.fg(tone, STATUS_BULLET); + this.data.phase === 'failed' || this.data.phase === 'killed' + ? currentTheme.fg(tone, FAILURE_MARK) + : currentTheme.fg(tone, STATUS_BULLET); const text = currentTheme.fg(tone, this.data.headline) + (this.data.detail !== undefined && this.data.detail.length > 0 diff --git a/apps/pythinker-code/src/tui/components/messages/sticky-user-message.ts b/apps/pythinker-code/src/tui/components/messages/sticky-user-message.ts index 2883dfb8d..102405cc7 100644 --- a/apps/pythinker-code/src/tui/components/messages/sticky-user-message.ts +++ b/apps/pythinker-code/src/tui/components/messages/sticky-user-message.ts @@ -9,6 +9,7 @@ import { TRUNCATION_ELLIPSIS } from '#/tui/constant/rendering'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import type { StickyUserMessageJudgment } from '#/tui/utils/sticky-user-message'; +import { UserMessageComponent } from './user-message'; export class StickyUserMessageComponent implements Component { private judgment: StickyUserMessageJudgment | null = null; @@ -33,12 +34,11 @@ export class StickyUserMessageComponent implements Component { const safeWidth = Math.max(0, width); if (this.judgment === null || safeWidth <= 0) return []; - const line = truncateToWidth( - USER_MESSAGE_BULLET + this.judgment.summary, - safeWidth, - TRUNCATION_ELLIPSIS, - ); - return [currentTheme.boldFg('roleUser', line)]; + const text = this.judgment.component instanceof UserMessageComponent + ? currentTheme.boldFg('roleUser', USER_MESSAGE_BULLET) + + this.judgment.component.colorText(this.judgment.summary) + : currentTheme.boldFg('roleUser', USER_MESSAGE_BULLET + this.judgment.summary); + return [truncateToWidth(text, safeWidth, TRUNCATION_ELLIPSIS)]; } handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined { diff --git a/apps/pythinker-code/src/tui/components/messages/tool-call.ts b/apps/pythinker-code/src/tui/components/messages/tool-call.ts index f8a374516..81f9cd771 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-call.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-call.ts @@ -469,7 +469,7 @@ export function extractKeyArgumentDetail( // Prefer the short `description` so the header preview never spills a // multi-line `prompt` into the TUI chrome. Agent: ['description', 'prompt'], - NotifyUser: ['message'], + NotifyUser: ['title', 'message'], }; // Glob: concatenate multiple args into a single summary so the header @@ -1738,7 +1738,8 @@ export class ToolCallComponent extends Container { if (toolCall.name === 'NotifyUser' && isExperimentalFlagEnabled('notify_user')) { // The update itself lives in the panel above the input box; the card // is the durable trace in the transcript, so the header carries the - // first line and ctrl+o shows the whole message. + // title (or the message's first line for updates without one) and + // ctrl+o shows the whole message. if (isTruncated) { // max_tokens cut the arguments short: the call never ran and the // panel entry was dropped, so the card must not read as in flight. diff --git a/apps/pythinker-code/src/tui/components/messages/user-message.ts b/apps/pythinker-code/src/tui/components/messages/user-message.ts index dad90a769..2285a5cf8 100644 --- a/apps/pythinker-code/src/tui/components/messages/user-message.ts +++ b/apps/pythinker-code/src/tui/components/messages/user-message.ts @@ -15,6 +15,7 @@ import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; +import { findInlineSkillTokens } from '#/tui/utils/inline-skill-tokens'; import { markOsc133Zone } from '#/tui/utils/osc133'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; @@ -36,14 +37,21 @@ export function userMessageLineHeights(text: string, width: number, bullet?: str export class UserMessageComponent implements Component { private text: string; private readonly bullet?: string; + private readonly skillNames?: readonly string[]; private spacerComponent: Spacer; private imageThumbnails: ImageThumbnail[]; private renderCache: { width: number; lines: string[] } | undefined; - constructor(text: string, images?: ImageAttachment[], bullet?: string) { + constructor( + text: string, + images?: ImageAttachment[], + bullet?: string, + skillNames?: readonly string[], + ) { this.text = text; this.bullet = bullet; + this.skillNames = skillNames; this.spacerComponent = new Spacer(1); this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? []; } @@ -52,6 +60,37 @@ export class UserMessageComponent implements Component { this.renderCache = undefined; } + colorText( + text: string, + paint: (segment: string) => string = (segment) => currentTheme.boldFg('roleUser', segment), + ): string { + const names = this.skillNames; + if (names === undefined || names.length === 0) { + return paint(text); + } + const known = new Set(names.map(normalizeSkillTokenName)); + const tokens = findInlineSkillTokens(text, { + isKnownSkill: (commandName) => known.has(normalizeSkillTokenName(commandName)), + includeLeading: true, + }); + if (tokens.length === 0) { + return paint(text); + } + const segments: string[] = []; + let cursor = 0; + for (const token of tokens) { + if (token.start > cursor) { + segments.push(paint(text.slice(cursor, token.start))); + } + segments.push(currentTheme.boldFg('primary', text.slice(token.start, token.end))); + cursor = token.end; + } + if (cursor < text.length) { + segments.push(paint(text.slice(cursor))); + } + return segments.join(''); + } + invalidate(): void { this.markRenderDirty(); for (const img of this.imageThumbnails) { @@ -86,8 +125,10 @@ export class UserMessageComponent implements Component { // Text is re-dyed from the current theme; invalidate() (theme change) clears // the render cache so the new colours are picked up on the next render. - const coloredText = isRoleMessage ? currentTheme.fg('textStrong', this.text) : this.text; - const textLines = new Text(coloredText, 0, 0).render(contentWidth); + const paint = isRoleMessage + ? (segment: string) => currentTheme.fg('textStrong', segment) + : (segment: string) => segment; + const textLines = new Text(this.colorText(this.text, paint), 0, 0).render(contentWidth); for (let i = 0; i < textLines.length; i++) { const prefix = i === 0 ? bullet : ' '.repeat(bulletWidth); const line = prefix + textLines[i]; @@ -127,6 +168,10 @@ export class UserMessageComponent implements Component { } } +function normalizeSkillTokenName(name: string): string { + return name.startsWith('skill:') ? name.slice('skill:'.length) : name; +} + function isImageLine(line: string): boolean { return line.includes('\u001B_G') || line.includes('\u001B]1337;File='); } diff --git a/apps/pythinker-code/src/tui/constant/pythinker-tui.ts b/apps/pythinker-code/src/tui/constant/pythinker-tui.ts index 865bbe275..60c794f95 100644 --- a/apps/pythinker-code/src/tui/constant/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/constant/pythinker-tui.ts @@ -4,13 +4,12 @@ export const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login'; export const NO_ACTIVE_SESSION_MESSAGE = 'No active session. Send /login to login.'; export const CTRL_D_HINT = 'Press Ctrl+D again to exit'; export const CTRL_C_HINT = 'Press Ctrl+C again to exit'; +export const SHUTTING_DOWN_HINT = 'Shutting down… (running shutdown hooks)'; export const MAIN_AGENT_ID = 'main'; export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; export const TUI_MODE_RESTART_NOTICE = 'TUI mode takes effect after restarting Pythinker Code.'; export const SESSIONLESS_STARTUP_NOTICE = 'No session yet — one will be created on your first message.'; -export const TOWER_STATUS_PROMPT = - 'Report the current tower status: call TowerStatus and give a compact summary.'; export const TOWER_TEARDOWN_PROMPT = 'Tear down the tower: call TowerTeardown and report what it did. It refuses to destroy dirty worktrees unless forced.'; export const EXIT_CONFIRM_WINDOW_MS = 1500; diff --git a/apps/pythinker-code/src/tui/controllers/auth-flow.ts b/apps/pythinker-code/src/tui/controllers/auth-flow.ts index 6ee720c0d..b7bbd103e 100644 --- a/apps/pythinker-code/src/tui/controllers/auth-flow.ts +++ b/apps/pythinker-code/src/tui/controllers/auth-flow.ts @@ -1,19 +1,11 @@ import { - removeProviderFromConfig, - type PythinkerConfig, type PythinkerHarness, + type RefreshProviderModelsResponse, type Session, type ThinkingEffort, } from '@pymodel/pythinker-code-sdk'; -import { createPythinkerCodeUserAgent } from '#/cli/version'; - import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/pythinker-tui'; -import { - refreshAllProviderModels, - type RefreshProviderHost, - type RefreshResult, -} from '../utils/refresh-providers'; import { thinkingEffortFromConfig } from '../utils/thinking-config'; import type { AppState, PythinkerTUIOptions } from '../types'; @@ -161,8 +153,8 @@ export class AuthFlowController { * config. Runs best-effort: individual provider failures are collected * and returned instead of thrown. */ - async refreshProviderModels(): Promise { - const result = await refreshAllProviderModels(this.buildRefreshHost()); + async refreshProviderModels(): Promise { + const result = await this.host.harness.refreshProviderModels(); if (result.changed.length > 0) { await this.refreshAvailableModels(); } @@ -171,59 +163,7 @@ export class AuthFlowController { /** The model picker's refresh. No provider is OAuth-only to refresh, so it refreshes every provider, like `refreshProviderModels`. */ - async refreshOAuthProviderModels(): Promise { + async refreshOAuthProviderModels(): Promise { return this.refreshProviderModels(); } - - /** - * Build the refresh orchestrator's persistence host. When the harness can - * persist several config sections as ONE atomic write (the v2 engine's - * `replaceSections`), the orchestrator's two-phase contract (removeProvider - * then setConfig) is absorbed the same way the v2 engine's own refresh path - * does it: the removal is staged in memory only, and the following - * setConfig persists the complete records in a single write — so a process - * exit mid-refresh can never leave config.toml in a "provider removed, not - * yet restored" state. The v1 harness keeps the legacy host (two - * whole-document writes, each atomic on its own). - */ - private buildRefreshHost(): RefreshProviderHost { - const { host } = this; - const userAgent = createPythinkerCodeUserAgent(); - if (!host.harness.supportsAtomicSectionReplace()) { - return { - getConfig: () => host.harness.getConfig({ reload: true }), - removeProvider: (id) => host.harness.removeProvider(id), - setConfig: (patch) => host.harness.setConfig(patch), - userAgent, - }; - } - let staged: PythinkerConfig | undefined; - const requireStaged = (): PythinkerConfig => { - if (staged === undefined) { - throw new Error('refresh host: getConfig must be called before writes'); - } - return staged; - }; - return { - getConfig: async () => { - staged = await host.harness.getConfig({ reload: true }); - return staged; - }, - removeProvider: (id) => { - staged = removeProviderFromConfig(requireStaged(), id); - return Promise.resolve(staged); - }, - setConfig: async (patch) => { - // The orchestrator always passes complete records (built from a full - // clone), so the Partial-shaped patch is a full PythinkerConfig overlay. - staged = { ...requireStaged(), ...patch } as PythinkerConfig; - // Object.entries keeps keys whose value is `undefined`, so a cleared - // section (e.g. a dangling defaultModel) is expressed as a removal in - // the atomic write; sections absent from the patch stay untouched. - await host.harness.replaceConfigSections(Object.fromEntries(Object.entries(patch))); - return staged; - }, - userAgent, - }; - } } diff --git a/apps/pythinker-code/src/tui/controllers/btw-panel.ts b/apps/pythinker-code/src/tui/controllers/btw-panel.ts index cef7e3ecd..b4a98c4df 100644 --- a/apps/pythinker-code/src/tui/controllers/btw-panel.ts +++ b/apps/pythinker-code/src/tui/controllers/btw-panel.ts @@ -11,7 +11,17 @@ import { BtwPanelComponent } from '../components/panes/btw-panel'; import { formatErrorMessage } from '../utils/event-payload'; import { formatHookResultPlain } from '../utils/hook-result-format'; import { createMarkdownTheme } from '../theme/pi-tui-theme'; -import type { InlineSkillActivation } from '../types'; +import type { InlineSkillActivation, PreparedSkillActivations } from '../types'; +import { MEDIA_INGESTION_SUBMIT_WAIT_MS } from '../constant/media'; +import type { ImageAttachmentStore } from '../utils/image-attachment-store'; +import { + extractMediaAttachments, + originalsDirForSession, + pendingMediaIngestions, + resolveOriginalCaptions, + type ExtractionResult, +} from '../utils/image-placeholder'; +import type { StagingLease, StagingLeaseTracker } from './staging-leases'; import type { TUIState } from '../tui-state'; const BTW_BUSY_NOTICE = 'Wait for /btw to finish before sending another question.'; @@ -22,6 +32,9 @@ export interface BtwPanelHost { readonly harness: PythinkerHarness; showError(msg: string): void; + validateMediaCapabilities(extraction: ExtractionResult): boolean; + prepareSkillActivations(activations: readonly InlineSkillActivation[]): PreparedSkillActivations; + releaseSkillArgumentMedia(prepared: PreparedSkillActivations): void; } export class BtwPanelController { @@ -32,8 +45,13 @@ export class BtwPanelController { } | undefined; private readonly panelsByAgentId = new Map(); + private readonly cancelledAgentIds = new Set(); - constructor(private readonly host: BtwPanelHost) {} + constructor( + private readonly host: BtwPanelHost, + private readonly imageStore: ImageAttachmentStore, + private readonly staging: StagingLeaseTracker, + ) {} open( agentId: string, @@ -84,6 +102,7 @@ export class BtwPanelController { cancelRunning(): boolean { const active = this.active; if (active === undefined || !active.panel.isRunning()) return false; + this.cancelledAgentIds.add(active.agentId); void this.cancelAgent(active.agentId); return true; } @@ -109,6 +128,8 @@ export class BtwPanelController { } routeEvent(event: Event): boolean { + if (event.type === 'turn.started') this.staging.handleTurnStarted(event, event.agentId); + if (event.type === 'turn.ended') this.staging.handleTurnEnded(event, event.agentId); const panel = this.panelsByAgentId.get(event.agentId); if (panel === undefined) return false; @@ -126,6 +147,7 @@ export class BtwPanelController { this.host.state.ui.requestRender(); return true; case 'turn.ended': + this.cancelledAgentIds.delete(event.agentId); if (event.reason === 'completed') { panel.markDone(); } else { @@ -160,6 +182,7 @@ export class BtwPanelController { for (const [agentId, candidate] of this.panelsByAgentId) { if (candidate === panel) { this.panelsByAgentId.delete(agentId); + this.cancelledAgentIds.delete(agentId); } } if (this.active?.panel === panel) this.active = undefined; @@ -188,14 +211,7 @@ export class BtwPanelController { } const send = inlineSkillActivations !== undefined && inlineSkillActivations.length > 0 - ? () => - session.promptWithSkills( - prompt, - inlineSkillActivations.map((activation) => ({ - name: activation.skillName, - args: activation.args, - })), - ) + ? () => this.promptWithSkills(agentId, session, prompt, panel, inlineSkillActivations) : () => session.prompt(prompt); void this.withInteractiveAgent(agentId, send).catch((error: unknown) => { panel.markFailed(`Failed to send /btw prompt: ${formatErrorMessage(error)}`); @@ -203,6 +219,67 @@ export class BtwPanelController { }); } + private async promptWithSkills( + agentId: string, + session: Session, + prompt: string, + panel: BtwPanelComponent, + activations: readonly InlineSkillActivation[], + ): Promise { + let extraction: ExtractionResult | undefined; + let prepared: PreparedSkillActivations | undefined; + let lease: StagingLease | undefined; + try { + const ingestionWait = pendingMediaIngestions( + prompt, this.imageStore, MEDIA_INGESTION_SUBMIT_WAIT_MS, + ); + if (ingestionWait !== undefined) await ingestionWait; + // Ctrl-C during the media wait: no child turn existed for cancel() to + // stop, so settle the question here instead of dispatching it anyway. + if (this.cancelledAgentIds.delete(agentId)) { + panel.markFailed('Interrupted by user'); + return; + } + if ( + this.host.session !== session || + this.panelsByAgentId.get(agentId) !== panel || + !panel.isRunning() + ) return; + extraction = extractMediaAttachments(prompt, this.imageStore); + if (!this.host.validateMediaCapabilities(extraction)) { + throw new Error('Current model does not support this media input.'); + } + prepared = this.host.prepareSkillActivations(activations); + const input = extraction.hasMedia + ? resolveOriginalCaptions( + extraction.parts, extraction.imageAttachmentIds, this.imageStore, + originalsDirForSession(session), + ) + : prompt; + lease = this.staging.create( + [ + ...new Set([...extraction.imageAttachmentIds, ...extraction.videoAttachmentIds]), + ...prepared.mediaAttachmentIds, + ], + prepared.stagingPaths, + 'user', + undefined, + agentId, + ); + const request = session.promptWithSkills(input, prepared.skills); + this.staging.trackDispatch(lease, request, () => {}); + await request; + } catch (error) { + if (lease === undefined) { + if (prepared !== undefined) this.host.releaseSkillArgumentMedia(prepared); + if (extraction !== undefined) { + this.staging.releaseMedia([...extraction.imageAttachmentIds, ...extraction.videoAttachmentIds], []); + } + } + throw error; + } + } + private async cancelAgent(agentId: string): Promise { const session = this.host.session; if (session === undefined) return; diff --git a/apps/pythinker-code/src/tui/controllers/cache-hint-controller.ts b/apps/pythinker-code/src/tui/controllers/cache-hint-controller.ts index 98701a263..6f88a3754 100644 --- a/apps/pythinker-code/src/tui/controllers/cache-hint-controller.ts +++ b/apps/pythinker-code/src/tui/controllers/cache-hint-controller.ts @@ -240,6 +240,10 @@ export class CacheHintController { dismissed: host.state.appState.cacheExpiryHint === false, }); if (decision.kind === 'skip') return; + // Another dialog (e.g. the model migration prompt) already owns the editor + // area: never replace it under the user's keystrokes. The seeded baseline + // leaves this cycle to the idle-submit path, which hints on the first prompt. + if (host.state.editorReplacementMounted) return; this.resumedSessions.add(session.id); // The resume dialog also covers this idle cycle: the first submit right // after it must not be intercepted again. diff --git a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts index e1895819a..f4fa524b8 100644 --- a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts @@ -27,11 +27,12 @@ import type { VideoAttachment, } from '../utils/image-attachment-store'; import { extractMediaAttachments, imageExtensionForMime } from '../utils/image-placeholder'; -import { extractInlineSkillActivations } from '../utils/inline-skill-tokens'; -import type { PendingExit, QueuedMessage, SteerInputItem } from '../types'; +import { resolveUserInput } from '../commands/resolve'; +import type { PendingExit, QueuedMessage } from '../types'; import type { TUIState } from '../tui-state'; import type { BtwPanelController } from './btw-panel'; import type { SurveyController } from './survey-controller'; +import type { ModelMigrationController } from './model-migration'; export interface EditorKeyboardHost { state: TUIState; @@ -47,9 +48,10 @@ export interface EditorKeyboardHost { handleUserInput(text: string): void; readonly btwPanelController: BtwPanelController; readonly surveyController: SurveyController; + readonly modelMigration?: ModelMigrationController; readonly skillCommandMap: Map; - steerMessage(session: Session, input: readonly SteerInputItem[]): void; - steerSkillActivation(session: Session, skillName: string, skillArgs: string): void; + readonly pluginCommandMap: Map; + steerQueuedMessagesIntoRunningTurn(manual?: boolean): void; validateMediaCapabilities(extraction: { hasMedia: boolean; imageAttachmentIds: readonly number[]; @@ -85,6 +87,7 @@ export interface EditorKeyboardHost { export class EditorKeyboardController { private pendingExit: PendingExit | null = null; private pendingUndoEsc: { readonly timer: ReturnType } | null = null; + private disposed = false; constructor( private readonly host: EditorKeyboardHost, @@ -100,7 +103,11 @@ export class EditorKeyboardController { host.handleUserInput(text); }; + // After dispose (shutdown), consume every key here so no shortcut can fire + // while the UI is being torn down — Ctrl+C is handled earlier, at the UI + // input-listener level. editor.onPreInput = (data: string) => { + if (this.disposed) return true; if (matchesKey(data, Key.escape)) this.clearPendingExit(); const consumed = host.surveyController.handlePreInput(data); if (consumed) this.clearPendingUndoEsc(); @@ -111,6 +118,7 @@ export class EditorKeyboardController { if (this.pendingExit) this.clearPendingExit(); host.surveyController.handleEditorChange(text); host.updateEditorBorderHighlight(text); + if (text.length === 0) host.modelMigration?.retry(); // Expanding paste markers costs a full-text pass, and only `/goal` // input can trip the objective length limit — so skip the expansion // for ordinary prompts. Submitted text is trimmed before dispatch, so @@ -306,129 +314,56 @@ export class EditorKeyboardController { if ( host.state.appState.streamingPhase === 'idle' || host.state.appState.streamingPhase === 'shell' || - host.state.appState.isCompacting - ) - return; - // An automatic steer of the queue is still in flight: steering more now - // could reach the model ahead of it, so the keypress is ignored for - // that brief window and the draft stays in the editor. - if (host.isSteeringQueuedMessages()) return; - const text = editor.getText().trim(); + host.state.appState.isCompacting || + host.isSteeringQueuedMessages() + ) return; + const text = (editor.getExpandedText?.() ?? editor.getText()).trim(); const editorIsBash = editor.inputMode === 'bash'; - - // Bash commands (`! …`) are not steerable: they stay queued so they run - // after the current task. Grouped inline-skill submissions are not - // steerable either — steer carries no skill activations, so they stay - // queued and submit intact when the session drains; the same applies to - // an editor draft carrying inline skill tokens. Steering stops at the - // first such bundle: items behind it stay queued too, or a later - // message would jump ahead of its bundle and reverse the conversational - // order. Everything else steers in queue order — plain text as a - // steered message, slash-skill items as activations fired into the - // running turn (never as literal text). - const queued = host.state.queuedMessages; - const firstBundle = queued.findIndex((m) => m.inlineSkillActivations !== undefined); - const windowBeforeFirstBundle = firstBundle === -1 ? queued : queued.slice(0, firstBundle); - const steerable = windowBeforeFirstBundle.filter((m) => m.mode !== 'bash'); - const editorHasInlineSkills = - !editorIsBash && - text.length > 0 && - extractInlineSkillActivations(text, host.skillCommandMap).length > 0; - - type SteerRun = - | { readonly kind: 'text'; readonly items: SteerInputItem[] } - | { readonly kind: 'skill'; readonly skillName: string; readonly skillArgs: string }; - const runs: SteerRun[] = []; - let textRun: SteerInputItem[] = []; - const flushTextRun = (): void => { - if (textRun.length > 0) { - runs.push({ kind: 'text', items: textRun }); - textRun = []; - } - }; - for (const m of steerable) { - if (m.mode === 'skill' && m.skillName !== undefined) { - flushTextRun(); - runs.push({ kind: 'skill', skillName: m.skillName, skillArgs: m.skillArgs ?? '' }); - continue; - } - const trimmed = m.text.trim(); - if (trimmed.length > 0) { - // Queued items carry the parts extracted when they were submitted - // (and were already capability-validated then). - textRun.push({ - text: trimmed, - parts: m.parts, - imageAttachmentIds: m.imageAttachmentIds, - videoAttachmentIds: m.videoAttachmentIds, - }); - } - } - let editorExtraction: ReturnType | undefined; - if (!editorIsBash && text.length > 0 && !editorHasInlineSkills && firstBundle === -1) { + let extraction: ReturnType | undefined; + if (!editorIsBash && text.length > 0) { try { - // Synchronous path: an image still ingesting in the background - // extracts to its inline fallback here (no bounded wait like - // `sendNormalUserInput` — this handler cannot await without - // interleaving queue/draft edits); a video still uploading refuses - // the submission instead (no inline form exists). - editorExtraction = extractMediaAttachments(text, this.imageStore); + extraction = extractMediaAttachments(text, this.imageStore); } catch (error) { - // Media expansion failed (e.g. the pasted video's upload is still - // in flight) — leave the queue and the editor draft untouched. host.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); return; } - textRun.push({ - text, - parts: editorExtraction.hasMedia ? editorExtraction.parts : undefined, - imageAttachmentIds: - editorExtraction.imageAttachmentIds.length > 0 - ? editorExtraction.imageAttachmentIds - : undefined, - videoAttachmentIds: - editorExtraction.videoAttachmentIds.length > 0 - ? editorExtraction.videoAttachmentIds - : undefined, - }); - } - flushTextRun(); - - if (runs.length > 0) { - // The editor draft is fresh input: gate it on the model's media - // capabilities before splicing the queue, so a rejection leaves the - // queue and the draft untouched. - if ( - editorExtraction !== undefined && - !host.validateMediaCapabilities(editorExtraction) - ) { + if (!host.validateMediaCapabilities(extraction)) { host.releaseStagingMedia([ - ...editorExtraction.imageAttachmentIds, - ...editorExtraction.videoAttachmentIds, + ...extraction.imageAttachmentIds, + ...extraction.videoAttachmentIds, ]); return; } - const session = host.session; - if (host.state.appState.model.trim().length === 0 || session === undefined) { - host.releaseStagingMedia([ - ...(editorExtraction?.imageAttachmentIds ?? []), - ...(editorExtraction?.videoAttachmentIds ?? []), - ]); - host.showError(LLM_NOT_SET_MESSAGE); - return; - } - host.state.queuedMessages = queued.filter( - (m, index) => m.mode === 'bash' || (firstBundle !== -1 && index >= firstBundle), - ); - if (!editorIsBash && !editorHasInlineSkills && firstBundle === -1) editor.setText(''); - for (const run of runs) { - if (run.kind === 'text') { - host.steerMessage(session, run.items); - } else { - host.steerSkillActivation(session, run.skillName, run.skillArgs); - } - } } + if (host.state.appState.model.trim().length === 0 || host.session === undefined) { + host.releaseStagingMedia([ + ...(extraction?.imageAttachmentIds ?? []), + ...(extraction?.videoAttachmentIds ?? []), + ]); + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + if (extraction !== undefined) { + const { activations } = resolveUserInput({ + input: text, + skillCommandMap: host.skillCommandMap, + pluginCommandMap: host.pluginCommandMap, + isStreaming: true, + isCompacting: host.state.appState.isCompacting, + }); + host.state.queuedMessages.push({ + text, + agentId: host.harness?.interactiveAgentId, + parts: extraction.hasMedia ? extraction.parts : undefined, + imageAttachmentIds: + extraction.imageAttachmentIds.length > 0 ? extraction.imageAttachmentIds : undefined, + videoAttachmentIds: + extraction.videoAttachmentIds.length > 0 ? extraction.videoAttachmentIds : undefined, + inlineSkillActivations: activations.length > 0 ? activations : undefined, + }); + editor.setText(''); + } + host.steerQueuedMessagesIntoRunningTurn(true); host.updateQueueDisplay(); host.state.ui.requestRender(); }; @@ -487,6 +422,7 @@ export class EditorKeyboardController { } dispose(): void { + this.disposed = true; this.clearPendingExit(); this.clearPendingUndoEsc(); } diff --git a/apps/pythinker-code/src/tui/controllers/model-migration.ts b/apps/pythinker-code/src/tui/controllers/model-migration.ts new file mode 100644 index 000000000..efb40a705 --- /dev/null +++ b/apps/pythinker-code/src/tui/controllers/model-migration.ts @@ -0,0 +1,280 @@ +import type { + ModelAlias, + RefreshProviderModelsResponse, + ThinkingEffort, +} from '@pymodel/pythinker-code-sdk'; + +import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; +import { + defaultThinkingEffortFor, + modelDisplayName, + segmentsFor, + thinkingAvailability, +} from '../components/dialogs/model-selector'; +import type { SlashCommandHost } from '../commands/dispatch'; +import { + effectiveModelForHost, + hasConversationHistory, + lazyModelSelection, + MODEL_SWITCH_CACHE_WARNING, + performModelSwitch, +} from '../utils/model-switch'; +import { sanitizeShellOutput } from '../utils/shell-output'; +import { + acknowledgeModelMigration, + readModelMigrationAcknowledgements, +} from '#/utils/model-migration-state'; + +interface ModelMigration { + readonly fromAlias: string; + readonly fromModel: string; + readonly toAlias: string; + readonly toModel: string; + readonly displayName: string; + readonly effort: ThinkingEffort; + readonly effortHint?: string; + readonly migrationMarkdown: string; + readonly retirementAt?: string | null; +} + +type RetiredDefault = NonNullable; + +// The live effort is a user choice only when the source model can think at +// all; keep it when the target supports it, else use the target's default. +function migrationEffort(host: SlashCommandHost, from: ModelAlias, to: ModelAlias): ThinkingEffort { + const targetModel = effectiveModelForHost(host, to); + const liveEffort = host.state.appState.thinkingEffort; + return thinkingAvailability(effectiveModelForHost(host, from)) !== 'unsupported' && + segmentsFor(targetModel).includes(liveEffort) + ? liveEffort + : defaultThinkingEffortFor(targetModel); +} + +function findModelMigration( + host: SlashCommandHost, + acknowledged: Readonly>, +): ModelMigration | undefined { + const { model: fromAlias, availableModels } = host.state.appState; + const from = availableModels[fromAlias]; + const upgrade = from?.upgrade; + if (from === undefined || upgrade === undefined || upgrade.model === fromAlias) return; + + const target = Object.entries(availableModels).find( + ([alias, candidate]) => + alias !== fromAlias && candidate.provider === from.provider && alias === upgrade.model, + ); + if (target === undefined) return; + const [toAlias, to] = target; + if (acknowledged[fromAlias] === to.model) return; + const targetModel = effectiveModelForHost(host, to); + const effort = migrationEffort(host, from, to); + const migrationMarkdown = upgrade.migrationMarkdown.trim(); + // Registry-supplied text reaches the terminal verbatim through pi-tui, so + // strip control sequences the same way captured command output is. + return { + fromAlias, + fromModel: from.model, + toAlias, + toModel: to.model, + displayName: sanitizeShellOutput(modelDisplayName(toAlias, targetModel)), + effort, + effortHint: + thinkingAvailability(targetModel) === 'unsupported' ? undefined : `thinking ${effort}`, + migrationMarkdown: sanitizeShellOutput( + migrationMarkdown.length > 0 + ? migrationMarkdown + : `We recommend switching from ${from.model} to ${to.model}.`, + ), + retirementAt: upgrade.retirementAt, + }; +} + +// Accepting switches models, which needs an idle session and no open dialog +// (the startup session picker may still resume a different session); the +// prompt also waits for an empty, idle editor. +function canShowModelMigrationPrompt(host: SlashCommandHost): boolean { + return ( + host.state.activeDialog === null && + !host.state.editorReplacementMounted && + !host.sessionReplacementInFlight && + !host.state.appState.isCompacting && + !host.state.appState.isReplaying && + host.state.appState.streamingPhase === 'idle' && + !host.state.externalEditorRunning && + host.state.queuedMessages.length === 0 && + !host.state.queuedMessageDispatchPending && + host.state.editor.getText().length === 0 + ); +} + +export class ModelMigrationController { + private pending = false; + private scheduled = false; + private retired: RetiredDefault | undefined; + // Read once per request rather than on every retry, which runs on each state + // change while a prompt is pending. + private acknowledged: Promise>> = Promise.resolve({}); + + constructor(private readonly host: SlashCommandHost & { readonly aborted: boolean }) {} + + request(): void { + this.pending = true; + this.acknowledged = readModelMigrationAcknowledgements(); + this.retry(); + } + + /** + * Reflects a default model that a refresh found dropped by its registry. The + * engine already moved the default (keeping the configured thinking) and + * every live agent bound to it, so only a session-less selection can still + * point at it: it follows the new default the way startup hydration would, + * keeping a session-only effort picked before the first prompt. + */ + async retire(retired: RetiredDefault): Promise { + this.retired = retired; + this.request(); + const onRetiredSelection = () => + this.host.session === undefined && this.host.state.appState.model === retired.from; + if (!onRetiredSelection()) return; + const config = await this.host.harness.getConfig(); + if (!onRetiredSelection()) return; + const { lazySessionThinking } = this.host.state.appState; + const selection = lazyModelSelection(config, retired.to); + this.host.setAppState( + lazySessionThinking === undefined + ? selection + : { ...selection, thinkingEffort: lazySessionThinking }, + ); + } + + retry(): void { + if (!this.pending || this.scheduled) return; + this.scheduled = true; + // Defer past editor submission and its lazy-session dispatch continuations. + queueMicrotask(() => { + void Promise.all([this.host.waitForSessionAssembly(), this.acknowledged]) + .then(([, acknowledged]) => { + setImmediate(() => { + this.scheduled = false; + if (this.host.aborted || this.host.state.appState.model.length === 0) return; + // The retirement notice waits only for the startup session picker + // or another dialog to close, not for an empty editor. + const retired = this.retired; + if ( + retired !== undefined && + this.host.state.activeDialog === null && + !this.host.state.editorReplacementMounted + ) { + this.retired = undefined; + showRetiredDefaultNotice(this.host, retired); + } + if (!canShowModelMigrationPrompt(this.host)) return; + this.pending = false; + void maybeShowModelMigrationPrompt(this.host, acknowledged, () => { + this.request(); + }); + }); + }) + .catch(() => { + this.scheduled = false; + this.host.showError('Failed to check model migration.'); + }); + }); + } +} + +export async function maybeShowModelMigrationPrompt( + host: SlashCommandHost, + acknowledged: Readonly>, + onDisplaced?: () => void, +): Promise { + const migration = findModelMigration(host, acknowledged); + if (migration === undefined || !canShowModelMigrationPrompt(host)) return; + + await new Promise((resolve) => { + let settled = false; + const finish = (switchModel: boolean) => { + if (settled) return; + settled = true; + host.restoreEditor(); + void (async () => { + if (switchModel) { + if (host.state.appState.availableModels[migration.toAlias] === undefined) { + resolve(); + return; + } + const switched = await performModelSwitch( + host, + migration.toAlias, + migration.effort, + 'model-and-effort', + ); + if (!switched) { + resolve(); + return; + } + } + await acknowledgeModelMigration(migration.fromAlias, migration.toModel); + resolve(); + })().catch(() => { + host.showError('Failed to save model migration preference.'); + resolve(); + }); + }; + + const notice = migrationNotice(migration); + host.mountEditorReplacement( + new ChoicePickerComponent({ + title: 'Model migration available', + notice: { + markdown: hasConversationHistory(host) + ? `${notice}\n\n${MODEL_SWITCH_CACHE_WARNING}` + : notice, + }, + options: [ + { + value: 'switch', + label: `Switch to ${migration.displayName}`, + description: migration.effortHint, + }, + { value: 'keep', label: 'Keep current model' }, + ], + currentValue: 'keep', + initialValue: 'switch', + spaceSelect: false, + onSelect: (value) => finish(value === 'switch'), + onCancel: () => { + if (settled) return; + settled = true; + host.restoreEditor(); + resolve(); + }, + }), + () => { + if (settled) return; + settled = true; + onDisplaced?.(); + resolve(); + }, + ); + }); +} + +function showRetiredDefaultNotice(host: SlashCommandHost, retired: RetiredDefault): void { + const fromName = sanitizeShellOutput(retired.from_display_name); + const toName = sanitizeShellOutput( + modelDisplayName(retired.to, host.state.appState.availableModels[retired.to]), + ); + host.showStatus( + `${fromName} is no longer offered by its provider; the default model is now ${toName}.`, + 'warning', + ); +} + +function migrationNotice(migration: ModelMigration): string { + const retirementAt = migration.retirementAt; + if (retirementAt === undefined || retirementAt === null) return migration.migrationMarkdown; + const timestamp = Date.parse(retirementAt); + if (!Number.isFinite(timestamp)) return migration.migrationMarkdown; + return `${migration.migrationMarkdown}\n\nRetirement date: ${new Date(timestamp).toLocaleDateString()}`; +} diff --git a/apps/pythinker-code/src/tui/controllers/notify.ts b/apps/pythinker-code/src/tui/controllers/notify.ts index dd981d421..0c49eaf07 100644 --- a/apps/pythinker-code/src/tui/controllers/notify.ts +++ b/apps/pythinker-code/src/tui/controllers/notify.ts @@ -12,6 +12,7 @@ interface PendingUpdate { readonly turnId: number; readonly step: number; readonly time: number; + readonly title?: string; readonly text: string; } @@ -200,13 +201,16 @@ export class NotifyController { const key = JSON.stringify([agentId, event.turnId, event.toolCallId]); if (this.settled.has(key)) return; if (event.name !== 'NotifyUser') return; - const message = argsRecord(event.args)['message']; + const args = argsRecord(event.args); + const message = args['message']; if (typeof message !== 'string' || message.trim().length === 0) return; + const title = typeof args['title'] === 'string' ? args['title'].trim() : ''; this.pending.set(key, { agentId, turnId: event.turnId, step: this.steps.get(agentId) ?? 0, time: Date.now(), + title: title.length > 0 ? title : undefined, text: message, }); break; @@ -236,6 +240,7 @@ export class NotifyController { agentId, agentName: this.agentNames.get(agentId), time: update.time, + title: update.title, text: update.text, }; this.state.notifyPanel.upsert(entry); diff --git a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts index 1798479e2..c5db78e2b 100644 --- a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts +++ b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts @@ -69,6 +69,7 @@ import { } from '../utils/mcp-server-status'; import { openUrl } from '#/utils/open-url'; import { McpLoadingLine } from '../components/messages/mcp-loading-line'; +import { StatusMessageComponent } from '../components/messages/status-message'; import type { ColorToken } from '#/tui/theme'; import { errorReportHintLine } from '../constant/feedback'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; @@ -115,6 +116,7 @@ export interface SessionEventHost { restoreEditor(): void; restoreInputText(text: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; + moveTranscriptEntryToEnd(entry: TranscriptEntry): boolean; handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void; handleShellStarted(event: { commandId: string; taskId: string }): void; sendNormalUserInput(text: string): void; @@ -142,6 +144,7 @@ export class SessionEventHandler { this.subAgentEventHandler = new SubAgentEventHandler(host, { backgroundTasks: this.backgroundTasks, backgroundTaskTranscriptedTerminal: this.backgroundTaskTranscriptedTerminal, + backgroundTaskTerminalEntries: this.backgroundTaskTerminalEntries, syncBackgroundAgentBadge: () => { this.syncBackgroundTaskBadge(); }, @@ -160,8 +163,9 @@ export class SessionEventHandler { // Runtime state – owned by this handler, reset between sessions. backgroundTasks: Map = new Map(); backgroundTaskTranscriptedTerminal: Set = new Set(); + private backgroundTaskTerminalEntries: Map = new Map(); - renderedSkillActivationIds: Set = new Set(); + seenSkillActivationIds: Set = new Set(); renderedPluginCommandActivationIds: Set = new Set(); renderedMcpServerStatusKeys: Map = new Map(); mcpLoadingLine: McpLoadingLine | undefined; @@ -180,9 +184,10 @@ export class SessionEventHandler { resetRuntimeState(): void { this.backgroundTasks.clear(); this.backgroundTaskTranscriptedTerminal.clear(); + this.backgroundTaskTerminalEntries.clear(); this.subAgentEventHandler.resetRuntimeState(); this.notifications.reset(); - this.renderedSkillActivationIds.clear(); + this.seenSkillActivationIds.clear(); this.renderedPluginCommandActivationIds.clear(); this.renderedMcpServerStatusKeys.clear(); this.mcpServers.clear(); @@ -335,6 +340,9 @@ export class SessionEventHandler { if (event.origin?.kind === 'plugin_command') { this.pluginCommandTurns.set(String(event.turnId), event.origin.pluginId); } + if (event.origin?.kind === 'task') { + this.anchorTaskNotificationTurn(event.origin.taskId, event.origin.status); + } this.clearAgentDynamicWorkflowProgress(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.setStep(0); @@ -727,6 +735,10 @@ export class SessionEventHandler { event.dynamicWorkflowMode === false && this.host.state.appState.dynamicWorkflowMode && this.host.state.dynamicWorkflowModeEntry === 'task'; + const shouldRenderTowerGoalExit = + event.towerMode === false && + this.host.state.appState.towerMode && + event.towerExitReason === 'mode-mutex'; const patch: Partial = {}; if (event.contextTokens !== undefined) patch.contextTokens = event.contextTokens; if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens; @@ -760,6 +772,12 @@ export class SessionEventHandler { this.renderDynamicWorkflowModeMarker('ended'); } } + if (shouldRenderTowerGoalExit) { + this.host.state.transcriptContainer.addChild( + new StatusMessageComponent('Tower mode ended — a goal was activated'), + ); + this.host.state.ui.requestRender(); + } } private renderDynamicWorkflowModeMarker(state: DynamicWorkflowModeMarkerState): void { @@ -1065,8 +1083,9 @@ export class SessionEventHandler { } private handleSkillActivated(event: SkillActivatedEvent): void { - if (this.renderedSkillActivationIds.has(event.activationId)) return; - this.renderedSkillActivationIds.add(event.activationId); + if (this.seenSkillActivationIds.has(event.activationId)) return; + this.seenSkillActivationIds.add(event.activationId); + if (event.trigger === 'user-slash') return; this.host.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'skill_activation', @@ -1227,7 +1246,7 @@ export class SessionEventHandler { } if (!this.backgroundTaskTranscriptedTerminal.has(info.taskId)) { if (info.kind === 'process' || info.kind === 'question') { - this.appendBackgroundTaskEntry(info); + this.backgroundTaskTerminalEntries.set(info.taskId, this.appendBackgroundTaskEntry(info)); } this.backgroundTaskTranscriptedTerminal.add(info.taskId); } @@ -1242,7 +1261,7 @@ export class SessionEventHandler { this.host.tasksBrowserController.repaint(); } - private appendBackgroundTaskEntry(info: BackgroundTaskInfo): void { + private appendBackgroundTaskEntry(info: BackgroundTaskInfo): TranscriptEntry { const status = formatBackgroundTaskTranscript(info); const entry: TranscriptEntry = { id: nextTranscriptId(), @@ -1254,6 +1273,57 @@ export class SessionEventHandler { backgroundAgentStatus: status, }; this.host.appendTranscriptEntry(entry); + return entry; + } + + /** + * Give a task-notification turn its own fold-segment boundary at turn start: + * move the task's asynchronously mounted terminal card down to the turn, + * or mount one now when none is mounted (e.g. it was trimmed). Without an + * anchor the turn's end-of-turn fold would extend into the previous turn's + * output and collapse its final answer into the step summary. + */ + private anchorTaskNotificationTurn( + taskId: string, + status: BackgroundTaskInfo['status'], + ): void { + const mounted = this.backgroundTaskTerminalEntries.get(taskId); + if (mounted !== undefined && this.host.moveTranscriptEntryToEnd(mounted)) return; + const task = this.backgroundTasks.get(taskId); + if (task === undefined) return; + // An agent whose terminal lifecycle event landed before its task id was + // known (e.g. a tower worker that finished before registerTask) mounted + // its terminal card without registering it under the task id. Reuse that + // card instead of mounting a duplicate. + if (task.kind === 'agent') { + const orphaned = this.findTerminalBackgroundAgentCard(task.agentId, taskId); + if (orphaned !== undefined && this.host.moveTranscriptEntryToEnd(orphaned)) { + this.backgroundTaskTerminalEntries.set(taskId, orphaned); + return; + } + } + const entry = this.appendBackgroundTaskEntry({ ...task, status }); + this.backgroundTaskTerminalEntries.set(taskId, entry); + this.backgroundTaskTranscriptedTerminal.add(taskId); + } + + /** The agent's latest mounted terminal (non-`started`) background card. */ + private findTerminalBackgroundAgentCard( + agentId: string | undefined, + taskId: string, + ): TranscriptEntry | undefined { + if (agentId === undefined) return undefined; + return this.host.state.transcriptEntries.findLast((entry) => { + const card = entry.backgroundAgentStatus; + if (card?.agentId !== agentId || card.phase === 'started') return false; + // A resumed background agent keeps its previous agentId, so the latest + // matching card can belong to an earlier run. Never reuse a card that + // is already registered under another task — mount our own instead. + for (const [registeredTaskId, registered] of this.backgroundTaskTerminalEntries) { + if (registered === entry && registeredTaskId !== taskId) return false; + } + return true; + }); } private syncBackgroundTaskBadge(): void { diff --git a/apps/pythinker-code/src/tui/controllers/session-replay.ts b/apps/pythinker-code/src/tui/controllers/session-replay.ts index 96b197db0..0893f8baa 100644 --- a/apps/pythinker-code/src/tui/controllers/session-replay.ts +++ b/apps/pythinker-code/src/tui/controllers/session-replay.ts @@ -47,6 +47,7 @@ import { pluginCommandFromOrigin, toolCallFromReplayMessage, toolResultOutput, + userSlashSkillPromptText, withoutUserPromptSubmitHookParts, type BackgroundTaskNotificationOrigin, type ReplayRenderContext, @@ -408,10 +409,11 @@ export class SessionReplayRenderer { this.flushAssistant(context); const skill = skillActivationFromOrigin(message.origin); if (skill !== undefined) { - this.renderSkillActivation(context, skill); - if (message.origin?.kind === 'skill_activation' && message.origin.trigger === 'user-slash') { - this.advanceTurn(context); + if (skill.trigger === 'user-slash') { + this.renderUserSlashSkillPrompt(context, message, skill); + return; } + this.renderSkillActivation(context, skill); this.renderHookParts(context, message); return; } @@ -455,24 +457,32 @@ export class SessionReplayRenderer { message: ContextMessage, hookResults: readonly ContextMessage[] = [], ): void { - // The bundle is one message: advance once, rebuild the per-skill cards - // from the prompt origin, then show the caller's own parts (the engine - // prepends one rendered text part per bundled skill to the content). this.advanceTurn(context); - this.renderBundledSkillCards(context, message); + this.registerBundledSkillActivations(context, message); for (const hookResult of hookResults) { this.renderHookResult(context, hookResult); } this.renderHookParts(context, message); const callerMessage = { ...message, content: withoutUserPromptSubmitHookParts(message.content) }; - this.host.appendTranscriptEntry( - replayEntry(context, 'user', contentPartsToText(stripBundledSkillParts(callerMessage)), 'plain'), - ); + this.host.appendTranscriptEntry({ + ...replayEntry( + context, + 'user', + contentPartsToText(stripBundledSkillParts(callerMessage)), + 'plain', + ), + skillNames: bundledSkillsFromOrigin(message.origin).map((skill) => skill.skillName), + }); } - private renderBundledSkillCards(context: ReplayRenderContext, message: ContextMessage): void { + private registerBundledSkillActivations( + context: ReplayRenderContext, + message: ContextMessage, + ): void { + const { sessionEventHandler } = this.host; for (const skill of bundledSkillsFromOrigin(message.origin)) { - this.renderSkillActivation(context, skill); + context.skillActivationIds.add(skill.activationId); + sessionEventHandler.seenSkillActivationIds.add(skill.activationId); } } @@ -548,15 +558,37 @@ export class SessionReplayRenderer { // Special content renderers // --------------------------------------------------------------------------- + private renderUserSlashSkillPrompt( + context: ReplayRenderContext, + message: ContextMessage, + skill: SkillActivationProjection, + ): void { + const { sessionEventHandler } = this.host; + if (context.skillActivationIds.has(skill.activationId)) return; + if (sessionEventHandler.seenSkillActivationIds.has(skill.activationId)) return; + context.skillActivationIds.add(skill.activationId); + sessionEventHandler.seenSkillActivationIds.add(skill.activationId); + this.advanceTurn(context); + this.renderHookParts(context, message); + this.host.appendTranscriptEntry({ + ...replayEntry(context, 'user', userSlashSkillPromptText(message, skill), 'plain'), + skillNames: [skill.skillName], + skillActivationId: skill.activationId, + skillName: skill.skillName, + skillArgs: skill.skillArgs, + skillTrigger: skill.trigger, + }); + } + private renderSkillActivation( context: ReplayRenderContext, skill: SkillActivationProjection, ): void { const { sessionEventHandler } = this.host; if (context.skillActivationIds.has(skill.activationId)) return; - if (sessionEventHandler.renderedSkillActivationIds.has(skill.activationId)) return; + if (sessionEventHandler.seenSkillActivationIds.has(skill.activationId)) return; context.skillActivationIds.add(skill.activationId); - sessionEventHandler.renderedSkillActivationIds.add(skill.activationId); + sessionEventHandler.seenSkillActivationIds.add(skill.activationId); this.host.appendTranscriptEntry({ ...replayEntry(context, 'skill_activation', `Activated skill: ${skill.skillName}`, 'plain'), skillActivationId: skill.activationId, diff --git a/apps/pythinker-code/src/tui/controllers/staging-leases.ts b/apps/pythinker-code/src/tui/controllers/staging-leases.ts index 0ba1b37da..abd2ed611 100644 --- a/apps/pythinker-code/src/tui/controllers/staging-leases.ts +++ b/apps/pythinker-code/src/tui/controllers/staging-leases.ts @@ -26,10 +26,7 @@ * The tracker holds one lease per submission, binds it to the consuming turn * (explicitly at dispatch, by exact submission id when the turn echoes the * client-chosen prompt id, or heuristically when a matching-origin turn - * starts), and releases it when that turn ends. The heuristic claims the - * earliest unclaimed lease of the same origin; that is only sound because the - * TUI serializes same-origin dispatches (one in-flight submission at a time, - * see `beginSessionRequest`) and `turn.started` arrives in dispatch order. + * starts), and releases it when that turn ends. * * Exact binding: a lease created with a `submissionId` is registered in * `leasesBySubmissionId`, and the submission sends that id as the prompt id; @@ -37,13 +34,6 @@ * `handleTurnStarted` binds the exact lease instead of guessing. The * heuristic below remains the fallback for submissions without an id echo. * - * INVARIANT: at most one unclaimed lease per origin at any moment — with two - * or more, the heuristic cannot tell which submission the turn belongs to. - * `handleTurnStarted` reports a violation through the `warn` effect and still - * claims the earliest (a mis-claim only mis-times deletions, so it is not - * worth failing the turn over). An exact `promptId` hit bypasses the - * heuristic entirely, so it neither trips nor needs the invariant. - * * Unclaimed leases are released at session close / shutdown, and every * in-flight cleanup is drainable via {@link drain}. * @@ -54,6 +44,8 @@ import type { TurnEndedEvent, TurnStartedEvent } from '@pymodel/pythinker-code-sdk'; +import { MAIN_AGENT_ID } from '../constant/pythinker-tui'; + import type { QueuedMessage } from '../types'; export type StagingLeaseOrigin = 'user' | 'skill_activation' | 'plugin_command'; @@ -62,6 +54,7 @@ export interface StagingLease { readonly mediaAttachmentIds: readonly number[]; readonly paths: readonly string[]; readonly origin: StagingLeaseOrigin; + readonly agentId: string; readonly submissionId?: string; turnId: string | undefined; released: boolean; @@ -74,11 +67,6 @@ export interface StagingLeaseEffects { readonly releaseRetains: (mediaAttachmentIds: readonly number[]) => void; /** Delete staged files (daemon uploads + local cache copies); never rejects. */ readonly deleteFiles: (fileIds: readonly string[], paths: readonly string[]) => Promise; - /** - * Optional sink for invariant violations (see the INVARIANT note above). - * The tracker keeps operating; the warning exists to make a broken - * same-origin ordering assumption visible instead of mis-binding silently. - */ readonly warn?: (message: string) => void; } @@ -107,6 +95,7 @@ export class StagingLeaseTracker { paths: readonly string[], origin: StagingLeaseOrigin, submissionId?: string, + agentId = MAIN_AGENT_ID, ): StagingLease | undefined { // `mediaAttachmentIds` multiplicity is the retain count this lease must // release: each extraction/rewrite retains once per unique id, so callers @@ -118,6 +107,7 @@ export class StagingLeaseTracker { mediaAttachmentIds: [...mediaAttachmentIds], paths: [...paths], origin, + agentId, submissionId, turnId: undefined, released: false, @@ -130,15 +120,16 @@ export class StagingLeaseTracker { bindToTurn(lease: StagingLease | undefined, turnId: string): void { if (lease === undefined || lease.released || lease.turnId !== undefined) return; lease.turnId = turnId; - let leases = this.leasesByTurn.get(turnId); + const key = `${lease.agentId}:${turnId}`; + let leases = this.leasesByTurn.get(key); if (leases === undefined) { leases = new Set(); - this.leasesByTurn.set(turnId, leases); + this.leasesByTurn.set(key, leases); } leases.add(lease); } - handleTurnStarted(event: TurnStartedEvent): void { + handleTurnStarted(event: TurnStartedEvent, agentId = MAIN_AGENT_ID): void { const kind = event.origin?.kind; if (kind !== 'user' && kind !== 'skill_activation' && kind !== 'plugin_command') return; if (event.promptId !== undefined) { @@ -146,14 +137,15 @@ export class StagingLeaseTracker { // id — bind that lease directly and skip the origin heuristic (and its // ambiguity warning) entirely. const exact = this.leasesBySubmissionId.get(event.promptId); - if (exact !== undefined && exact.turnId === undefined) { + if (exact !== undefined && exact.turnId === undefined && exact.agentId === agentId) { this.bindToTurn(exact, String(event.turnId)); return; } } const candidates = [...this.leases].filter( (candidate) => - !candidate.released && candidate.turnId === undefined && candidate.origin === kind, + !candidate.released && candidate.turnId === undefined && + candidate.origin === kind && candidate.agentId === agentId, ); if (candidates.length > 1) { // INVARIANT violation: the earliest-unclaimed pick cannot tell @@ -167,9 +159,9 @@ export class StagingLeaseTracker { this.bindToTurn(candidates[0], String(event.turnId)); } - handleTurnEnded(event: TurnEndedEvent): void { - const turnId = String(event.turnId); - const leases = this.leasesByTurn.get(turnId); + handleTurnEnded(event: TurnEndedEvent, agentId = MAIN_AGENT_ID): void { + const key = `${agentId}:${String(event.turnId)}`; + const leases = this.leasesByTurn.get(key); if (leases === undefined) return; for (const lease of leases) { if (this.inFlight.has(lease)) { @@ -178,7 +170,7 @@ export class StagingLeaseTracker { } this.releaseConsumed(lease); } - if (this.leasesByTurn.get(turnId)?.size === 0) this.leasesByTurn.delete(turnId); + if (this.leasesByTurn.get(key)?.size === 0) this.leasesByTurn.delete(key); } /** @@ -318,9 +310,10 @@ export class StagingLeaseTracker { this.leases.delete(lease); if (lease.submissionId !== undefined) this.leasesBySubmissionId.delete(lease.submissionId); if (lease.turnId !== undefined) { - const leases = this.leasesByTurn.get(lease.turnId); + const key = `${lease.agentId}:${lease.turnId}`; + const leases = this.leasesByTurn.get(key); leases?.delete(lease); - if (leases?.size === 0) this.leasesByTurn.delete(lease.turnId); + if (leases?.size === 0) this.leasesByTurn.delete(key); } } diff --git a/apps/pythinker-code/src/tui/controllers/subagent-event-handler.ts b/apps/pythinker-code/src/tui/controllers/subagent-event-handler.ts index bcc59e5cd..013dc1608 100644 --- a/apps/pythinker-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/pythinker-code/src/tui/controllers/subagent-event-handler.ts @@ -13,6 +13,7 @@ import { modelDisplayName } from '../components/dialogs/model-selector'; import { MAIN_AGENT_ID } from '../constant/pythinker-tui'; import type { BackgroundAgentMetadata, + BackgroundAgentStatusPhase, ToolCallBlockData, ToolResultBlockData, TranscriptEntry, @@ -38,6 +39,7 @@ type SubagentLifecycleEventOf = export interface SubAgentEventHandlerDependencies { readonly backgroundTasks: ReadonlyMap; readonly backgroundTaskTranscriptedTerminal: Set; + readonly backgroundTaskTerminalEntries: Map; readonly syncBackgroundAgentBadge: () => void; } @@ -173,6 +175,9 @@ export class SubAgentEventHandler { case 'subagent.failed': this.handleSubagentFailed(event); return; + case 'subagent.cancelled': + this.handleSubagentCancelled(event); + return; } } @@ -303,11 +308,9 @@ export class SubAgentEventHandler { this.pruneForegroundOnlyRecord(event.subagentId); const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); if (backgroundMeta !== undefined) { - const taskId = this.findAgentTaskId( - event.subagentId, - backgroundMeta, - this.deps.backgroundTasks, - ); + const taskId = + backgroundMeta.taskId ?? + this.findAgentTaskId(event.subagentId, backgroundMeta, this.deps.backgroundTasks); this.backgroundAgentMetadata.delete(event.subagentId); this.deps.syncBackgroundAgentBadge(); if (taskId !== undefined && this.deps.backgroundTaskTranscriptedTerminal.has(taskId)) { @@ -318,7 +321,10 @@ export class SubAgentEventHandler { } const extras = event.resultSummary === undefined ? undefined : { resultSummary: event.resultSummary }; - this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); + const entry = this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); + if (taskId !== undefined) { + this.deps.backgroundTaskTerminalEntries.set(taskId, entry); + } return; } @@ -334,11 +340,9 @@ export class SubAgentEventHandler { this.pruneForegroundOnlyRecord(event.subagentId); const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); if (backgroundMeta !== undefined) { - const taskId = this.findAgentTaskId( - event.subagentId, - backgroundMeta, - this.deps.backgroundTasks, - ); + const taskId = + backgroundMeta.taskId ?? + this.findAgentTaskId(event.subagentId, backgroundMeta, this.deps.backgroundTasks); const task = taskId === undefined ? undefined : this.deps.backgroundTasks.get(taskId); this.backgroundAgentMetadata.delete(event.subagentId); this.deps.syncBackgroundAgentBadge(); @@ -357,7 +361,10 @@ export class SubAgentEventHandler { if (taskId !== undefined) { this.deps.backgroundTaskTranscriptedTerminal.add(taskId); } - this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); + const entry = this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); + if (taskId !== undefined) { + this.deps.backgroundTaskTerminalEntries.set(taskId, entry); + } return; } @@ -366,6 +373,41 @@ export class SubAgentEventHandler { this.handleForegroundSubagentFailed(event, info); } + private handleSubagentCancelled( + event: SubagentLifecycleEventOf<'subagent.cancelled'>, + ): void { + this.activityStore.markFailed(event.subagentId); + this.pruneForegroundOnlyRecord(event.subagentId); + const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); + if (backgroundMeta !== undefined) { + const taskId = + backgroundMeta.taskId ?? + this.findAgentTaskId(event.subagentId, backgroundMeta, this.deps.backgroundTasks); + this.backgroundAgentMetadata.delete(event.subagentId); + this.deps.syncBackgroundAgentBadge(); + this.host.streamingUI.applyBackgroundTaskTerminalStatus({ + agentId: event.subagentId, + description: backgroundMeta.description ?? '', + status: 'killed', + }); + if (taskId !== undefined && this.deps.backgroundTaskTranscriptedTerminal.has(taskId)) { + return; + } + if (taskId !== undefined) { + this.deps.backgroundTaskTranscriptedTerminal.add(taskId); + } + const entry = this.appendBackgroundAgentEntry('killed', backgroundMeta); + if (taskId !== undefined) { + this.deps.backgroundTaskTerminalEntries.set(taskId, entry); + } + return; + } + + const info = this.subagentInfo.get(event.subagentId); + if (info === undefined || info.runInBackground) return; + this.handleForegroundSubagentCancelled(event, info); + } + private findAgentTaskId( subagentId: string, meta: BackgroundAgentMetadata, @@ -421,14 +463,15 @@ export class SubAgentEventHandler { description: typeof description === 'string' ? description : undefined, model: this.spawnedModelDisplay(event), effort: this.subagentEffortDisplay(event.thinkingEffort), + taskId: event.taskId, }; } private appendBackgroundAgentEntry( - phase: 'started' | 'completed' | 'failed', + phase: BackgroundAgentStatusPhase, meta: BackgroundAgentMetadata, extras: { resultSummary?: string; error?: string } | undefined = undefined, - ): void { + ): TranscriptEntry { const status = formatBackgroundAgentTranscript(phase, meta, extras); const entry: TranscriptEntry = { id: nextTranscriptId(), @@ -440,6 +483,7 @@ export class SubAgentEventHandler { backgroundAgentStatus: status, }; this.host.appendTranscriptEntry(entry); + return entry; } private rememberSubagent( @@ -583,6 +627,24 @@ export class SubAgentEventHandler { this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); } + private handleForegroundSubagentCancelled( + event: SubagentLifecycleEventOf<'subagent.cancelled'>, + info: SubagentInfo, + ): void { + const { parentToolCallId } = info; + if (this.updateAgentDynamicWorkflowProgress(parentToolCallId, (progress) => { + progress.markCancelled(event.subagentId); + })) { + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + return; + } + + const tc = this.host.streamingUI.getToolComponent(parentToolCallId); + if (tc === undefined) return; + tc.onSubagentFailed({ error: 'Aborted by the user' }); + this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); + } + private applySubagentEventToDynamicWorkflowProgress( progress: AgentDynamicWorkflowProgressComponent, event: Event, @@ -765,7 +827,8 @@ function isSubagentLifecycleEvent(event: Event): event is SubagentLifecycleEvent event.type === 'subagent.started' || event.type === 'subagent.suspended' || event.type === 'subagent.completed' || - event.type === 'subagent.failed' + event.type === 'subagent.failed' || + event.type === 'subagent.cancelled' ); } diff --git a/apps/pythinker-code/src/tui/controllers/survey-controller.ts b/apps/pythinker-code/src/tui/controllers/survey-controller.ts index bfb0c8589..8489349c2 100644 --- a/apps/pythinker-code/src/tui/controllers/survey-controller.ts +++ b/apps/pythinker-code/src/tui/controllers/survey-controller.ts @@ -142,7 +142,8 @@ export class SurveyController { private readonly view: SurveyPanelView = { phase: 'open' }; private mounted = false; private mountedAt: number; - private userTurnCount = 0; + private userTurnsSinceMount = 0; + private initialUserTurnCount: number | undefined = 0; private lastShownAt: number | undefined; private userTurnsAtLastShown: number | undefined; private appearanceCount = 0; @@ -195,7 +196,8 @@ export class SurveyController { this.applyClose(); this.machine = SURVEY_MACHINE_CLOSED; this.mountedAt = this.now(); - this.userTurnCount = 0; + this.userTurnsSinceMount = 0; + this.initialUserTurnCount = 0; this.lastShownAt = undefined; this.userTurnsAtLastShown = undefined; this.appearanceCount = 0; @@ -244,7 +246,7 @@ export class SurveyController { this.clearIdleTimer(); this.notifyDisplaced(); if (userOrigin) { - this.userTurnCount += 1; + this.userTurnsSinceMount += 1; this.evaluationPending = false; this.pendingTraceId = undefined; } @@ -304,6 +306,10 @@ export class SurveyController { recordModel(this.subagentModels, event.model); } + seedUserTurnCount(count: number | undefined): void { + this.initialUserTurnCount = count; + } + seedFromResumedAgents(agents: Readonly>): void { this.subagentCount = Object.values(agents).filter( (agent) => @@ -508,12 +514,12 @@ export class SurveyController { session: { ...shared, mountedForMs: now - this.mountedAt, - userTurnsSinceMount: this.userTurnCount, + userTurnsSinceMount: this.userTurnsSinceMount, msSinceLastShown: this.lastShownAt === undefined ? undefined : now - this.lastShownAt, userTurnsSinceLastShown: this.userTurnsAtLastShown === undefined ? undefined - : this.userTurnCount - this.userTurnsAtLastShown, + : this.userTurnsSinceMount - this.userTurnsAtLastShown, sample: this.currentSample(), msSinceGlobalLastShown: this.globalLastShownAt === undefined ? undefined : this.wallNow() - this.globalLastShownAt, @@ -556,9 +562,9 @@ export class SurveyController { } private currentSample(): number { - if (this.stickySample?.turnCount !== this.userTurnCount) { + if (this.stickySample?.turnCount !== this.userTurnsSinceMount) { this.stickySample = { - turnCount: this.userTurnCount, + turnCount: this.userTurnsSinceMount, value: (this.deps.random ?? defaultDeps.random)(), }; } @@ -582,7 +588,7 @@ export class SurveyController { this.openedAt = shownAt; this.openedEditorText = this.host.state.editor.getText(); this.lastShownAt = shownAt; - this.userTurnsAtLastShown = this.userTurnCount; + this.userTurnsAtLastShown = this.userTurnsSinceMount; if (survey === 'long_context') this.longContextShownThisMount = true; this.globalLastShownAt = this.wallNow(); try { @@ -725,7 +731,10 @@ export class SurveyController { return { current_model: appState.model, pfc_model_id: resolveKfcModelId(appState), - user_turn_count: this.userTurnCount, + user_turn_count: + this.initialUserTurnCount === undefined + ? undefined + : this.initialUserTurnCount + this.userTurnsSinceMount, cumulative_tokens: appState.cumulativeTokens ?? 0, virtual_context_tokens: appState.contextTokens, tool_call_count: this.toolCallCount, diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index d2ab5a602..45614fb10 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -4,7 +4,7 @@ import { unlink } from 'node:fs/promises'; import { join } from 'node:path'; import type { DeviceAuthorization } from '@pymodel/pythinker-code-oauth'; -import { effectiveModelAlias, log } from '@pymodel/pythinker-code-sdk'; +import { log } from '@pymodel/pythinker-code-sdk'; import type { ApprovalRequest, ApprovalResponse, @@ -28,6 +28,8 @@ import { type Component, type Focusable, getCapabilities, + Key, + matchesKey, Spacer, TuiAltScreen, TuiMainScreen, @@ -80,7 +82,6 @@ import { } from './components/dialogs/approval-preview'; import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; -import { defaultThinkingEffortFor } from './components/dialogs/model-selector'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; import { TrustPromptComponent, type TrustPromptChoice } from './components/dialogs/trust-prompt'; @@ -120,6 +121,7 @@ import { PRODUCT_NAME, SESSION_LIST_PAGE_SIZE, SESSIONLESS_STARTUP_NOTICE, + SHUTTING_DOWN_HINT, } from './constant/pythinker-tui'; import { MEDIA_INGESTION_SUBMIT_WAIT_MS } from './constant/media'; import { CHROME_GUTTER } from './constant/rendering'; @@ -128,6 +130,7 @@ import { AuthFlowController } from './controllers/auth-flow'; import { BtwPanelController } from './controllers/btw-panel'; import { ClipboardImageHintController } from './controllers/clipboard-image-hint'; import { EditorKeyboardController } from './controllers/editor-keyboard'; +import { ModelMigrationController } from './controllers/model-migration'; import { SessionEventHandler } from './controllers/session-event-handler'; import { SessionReplayRenderer } from './controllers/session-replay'; import { StagingLeaseTracker, type StagingLease } from './controllers/staging-leases'; @@ -150,6 +153,7 @@ import { sumTokenUsage, type AppState, type InlineSkillActivation, + type PreparedSkillActivations, type PythinkerTUIOptions, type LivePaneState, type LoginProgressSpinnerHandle, @@ -170,9 +174,10 @@ import { import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; -import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; +import { ImageAttachmentStore, type ImageAttachment, type MediaAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments, + omitExpiredVideoFileRefs, originalsDirForSession, pendingMediaIngestions, refreshExpiringImageFileRefs, @@ -189,7 +194,7 @@ import { beginScreenTakeover, endScreenTakeover, type ScreenTakeover } from './u import { sessionRowsForPicker } from './utils/session-picker-rows'; import { formatStepRetryDetail, formatStepRetryLabel } from './utils/step-retry'; import { formatBashOutputForDisplay } from './utils/shell-output'; -import { thinkingEffortFromConfig } from './utils/thinking-config'; +import { lazyModelSelection } from './utils/model-switch'; import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; import { installTerminalFocusTracking } from './utils/terminal-focus'; import { notifyTerminalOnce } from './utils/terminal-notification'; @@ -309,6 +314,7 @@ interface SendMessageOptions { readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; readonly videoAttachmentIds?: readonly number[]; + readonly mediaSnapshots?: readonly MediaAttachment[]; readonly hasMedia?: boolean; /** * Lease pre-created at extraction time by `sendNormalUserInput`. Dispatch @@ -343,6 +349,8 @@ export class PythinkerTUI { state: TUIState; /** In-flight lazy session creation (v2 engine), shared by concurrent first-use triggers. */ private ensureSessionPromise: Promise | null = null; + /** In-flight `/new` session replacement, shared by concurrent `/new` triggers. */ + private newSessionPromise: Promise | null = null; private readonly cacheHint = new CacheHintController(this); /** Staged prompt media lifecycle (daemon uploads + cache copies) — see StagingLeaseTracker. */ private readonly staging: StagingLeaseTracker; @@ -395,6 +403,8 @@ export class PythinkerTUI { readonly tasksBrowserController: TasksBrowserController; readonly surveyController: SurveyController; readonly editorKeyboard: EditorKeyboardController; + readonly modelMigration: ModelMigrationController; + private editorReplacementOnDisplaced: (() => void) | undefined; /** Timer that auto-clears the one-shot "moved to background" footer hint. */ private detachHintClearTimer: ReturnType | undefined; @@ -501,7 +511,7 @@ export class PythinkerTUI { ); this.streamingUI = new StreamingUIController(this); this.authFlow = new AuthFlowController(this); - this.btwPanelController = new BtwPanelController(this); + this.btwPanelController = new BtwPanelController(this, this.imageStore, this.staging); this.sessionEventHandler = new SessionEventHandler(this); this.sessionReplay = new SessionReplayRenderer(this); this.tasksBrowserController = new TasksBrowserController(this); @@ -512,6 +522,7 @@ export class PythinkerTUI { }, telemetryDisabled: () => isTelemetryDisabledByEnv() || this.telemetryDisabled, }); + this.modelMigration = new ModelMigrationController(this); this.editorKeyboard = new EditorKeyboardController(this, this.imageStore); this.editorKeyboard.install(); this.buildLayout(); @@ -834,11 +845,14 @@ export class PythinkerTUI { const result = await this.authFlow.refreshProviderModels(); for (const c of result.changed) { if (c.added <= 0) continue; - this.showStatus(`${c.providerName} · +${String(c.added)} model${c.added > 1 ? 's' : ''}.`); + this.showStatus(`${c.provider_name} · +${String(c.added)} model${c.added > 1 ? 's' : ''}.`); } for (const f of result.failed) { this.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning'); } + if (result.retired_default !== undefined) { + await this.modelMigration.retire(result.retired_default); + } } catch { // Best-effort: startup must not crash on background refresh failures. } @@ -856,6 +870,9 @@ export class PythinkerTUI { void this.showConfigWarningsIfAny(); if (this.state.startupState === 'picker') { void this.bootstrapFromPicker(); + void this.backgroundRefreshPromise?.then(() => { + this.modelMigration.request(); + }); return; } if (shouldReplayHistory) { @@ -880,6 +897,9 @@ export class PythinkerTUI { } void this.refreshSkillCommands(this.session); void this.refreshPluginCommands(this.session); + void this.backgroundRefreshPromise?.then(() => { + this.modelMigration.request(); + }); } private async showSessionWarnings(session: Session): Promise { @@ -1003,7 +1023,10 @@ export class PythinkerTUI { } if (session !== undefined) { - await this.setSession(session); + await this.setSession( + session, + shouldReplayHistory ? session.getResumeState()?.agents[MAIN_AGENT_ID]?.userTurnCount : 0, + ); await this.syncRuntimeState(session); } this.applyStartupPermissionAndPlanToAppState(); @@ -1016,6 +1039,15 @@ export class PythinkerTUI { this.isShuttingDown = true; this.unregisterSignalHandlers(); this.aborted = true; + // A Ctrl+C during shutdown means "don't wait for slow hooks": skip the + // remaining graceful close and get out immediately. Captured at the UI + // input-listener level so it works no matter which component holds focus + // (a dialog may have replaced the editor). + const removeForceExitListener = this.state.ui.addInputListener((data) => { + if (!matchesKey(data, Key.ctrl('c'))) return undefined; + this.forceExitDuringShutdown(); + return { consume: true }; + }); // Give the startup provider-model refresh a brief chance to finish before // the harness closes (and the process exits): its config writes are each // atomic, so draining can only ever leave a complete file behind. Bounded @@ -1044,6 +1076,11 @@ export class PythinkerTUI { } this.reverseRpcDisposers.length = 0; this.disposeTerminalTracking(); + // closeSession runs SessionEnd hooks, which can take seconds (bounded by + // the hook timeout) — keep the last frame from looking like a freeze by + // saying the shutdown is in progress. + this.state.footer.setTransientHint(SHUTTING_DOWN_HINT); + this.state.ui.requestRender(); // Restore the terminal even if closing the session / harness throws — a // SIGTERM during a network or MCP shutdown must not leave the user stuck in // raw mode with a hidden cursor. @@ -1055,6 +1092,7 @@ export class PythinkerTUI { await this.staging.drain(); await this.harness.close(); } finally { + removeForceExitListener(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.sessionEventHandler.clearStepRetryAttemptTimer(); this.uninstallRainbowDance(); @@ -1074,6 +1112,23 @@ export class PythinkerTUI { } } + // Ctrl+C while shutdown hooks run → skip the remaining graceful close. The + // terminal is still alive (unlike the SIGHUP path), so run the full UI + // teardown — alternate screen, mouse reporting, autowrap — and the CLI's + // stty restore via onExit, instead of the bare emergency sequences. + private forceExitDuringShutdown(): void { + try { + this.stopUiForExit(); + } catch { + restoreTerminalModes(); + } + if (this.onExit) { + void this.onExit(130); + } else { + process.exit(130); + } + } + // SIGHUP / dead-terminal EIO → emergencyTerminalExit (no cleanup, avoids // EIO write-loop that can pin a CPU core). SIGTERM → normal stop(). private registerSignalHandlers(): void { @@ -1492,94 +1547,173 @@ export class PythinkerTUI { this.showError(LLM_NOT_SET_MESSAGE); return; } - let extraction: ReturnType; + const originatingSession = this.session; + const mediaGeneration = this.imageStore.generation; + let extraction: ExtractionResult; + if (preExtracted === undefined) { + const ingestionWait = pendingMediaIngestions( + text, + this.imageStore, + MEDIA_INGESTION_SUBMIT_WAIT_MS, + ); + if (ingestionWait !== undefined) await ingestionWait; + if ( + (originatingSession !== undefined && this.session !== originatingSession) || + this.imageStore.generation !== mediaGeneration + ) { + this.showError('Session changed while preparing media attachment; skill prompt not sent.'); + return; + } + } try { extraction = preExtracted ?? extractMediaAttachments(text, this.imageStore); + if ( + preExtracted?.mediaGeneration !== undefined && + preExtracted.mediaGeneration !== this.imageStore.generation + ) { + extraction = { ...preExtracted, imageAttachmentIds: [], videoAttachmentIds: [] }; + } } catch (error) { this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); return; } - if (!this.validateMediaCapabilities(extraction)) return; + if (!this.validateMediaCapabilities(extraction)) { + this.staging.releaseMedia([ + ...extraction.imageAttachmentIds, ...extraction.videoAttachmentIds, + ], []); + return; + } if (this.cacheHint.maybeInterceptOnSubmit(text, extraction, activations)) return; let session = this.session; if (session === undefined) { - // Dispatch only routes here on the v2 engine, so the session is created - // lazily on first use exactly like a normal prompt. session = await this.ensureSession(); - if (session === undefined) return; + if (session === undefined) { + this.staging.releaseMedia([ + ...extraction.imageAttachmentIds, ...extraction.videoAttachmentIds, + ], []); + return; + } } if ( this.deferUserMessages || - this.state.appState.goal?.status === 'active' || + this.steeringQueuedMessages.size > 0 || this.state.appState.streamingPhase !== 'idle' || this.state.appState.isCompacting ) { - this.enqueueMessage( - text, - extraction.hasMedia - ? { - hasMedia: true, - parts: extraction.parts, - imageAttachmentIds: extraction.imageAttachmentIds, - videoAttachmentIds: extraction.videoAttachmentIds, - inlineSkillActivations: activations, - } - : { inlineSkillActivations: activations }, - ); + this.enqueueMessage(text, { + parts: extraction.hasMedia ? extraction.parts : undefined, + imageAttachmentIds: extraction.imageAttachmentIds, + videoAttachmentIds: extraction.videoAttachmentIds, + mediaSnapshots: extraction.mediaSnapshots, + inlineSkillActivations: activations, + }); this.updateQueueDisplay(); this.state.ui.requestRender(); return; } this.beginSessionRequest(); - void this.runInlineSkillActivations(session, text, activations, extraction).catch( - (error: unknown) => { - this.failSessionRequest(`Skill activation failed: ${formatErrorMessage(error)}`); - }, - ); + this.runInlineSkillActivations(session, text, activations, extraction); + } + + prepareSkillActivations( + activations: readonly InlineSkillActivation[], + mediaSnapshots?: readonly MediaAttachment[], + ): PreparedSkillActivations { + const prepared: PreparedSkillActivations = { + skills: [], + mediaAttachmentIds: [], + stagingPaths: [], + cachedVideos: [], + }; + try { + for (const activation of activations) { + if (activation.args === undefined) { + prepared.skills.push({ name: activation.skillName }); + continue; + } + const rewrite = rewriteMediaPlaceholders( + activation.args, this.imageStore, 'plain', mediaSnapshots, + ); + this.imageStore.retainFileIds(rewrite.videoAttachmentIds); + prepared.skills.push({ name: activation.skillName, args: rewrite.text }); + prepared.mediaAttachmentIds.push( + ...new Set([...rewrite.imageAttachmentIds, ...rewrite.videoAttachmentIds]), + ); + prepared.stagingPaths.push(...rewrite.stagingPaths); + prepared.cachedVideos.push(...rewrite.cachedVideos); + } + return prepared; + } catch (error) { + this.releaseSkillArgumentMedia(prepared); + throw error; + } } - private async runInlineSkillActivations( + releaseSkillArgumentMedia(prepared: PreparedSkillActivations): void { + for (const id of prepared.mediaAttachmentIds) this.staging.releaseRecalled([id]); + this.staging.releaseMedia([], prepared.stagingPaths); + } + + private runInlineSkillActivations( session: Session, text: string, activations: readonly InlineSkillActivation[], extraction: ReturnType, - ): Promise { - const knownEntryIds = new Set(this.state.transcriptEntries.map((entry) => entry.id)); - await session.promptWithSkills( - extraction.hasMedia + ): void { + let prepared: PreparedSkillActivations | undefined; + let input: string | readonly PromptPart[]; + try { + prepared = this.prepareSkillActivations(activations, extraction.mediaSnapshots); + input = extraction.hasMedia ? resolveOriginalCaptions( - extraction.parts, + omitExpiredVideoFileRefs(extraction.parts, prepared.cachedVideos), extraction.imageAttachmentIds, this.imageStore, originalsDirForSession(session), ) - : text, - activations.map((activation) => ({ name: activation.skillName, args: activation.args })), - ); - // The engine bundles the activations into the prompt's own message, and - // the `skill.activated` events land synchronously during the call — so - // the cards appended for this submission are the skill_activation entries - // with fresh ids (the window trim may replace the entries array mid-call, - // so membership is decided by id, not by index into a captured array). - // Appending the user entry afterwards keeps the live transcript in the - // same order as a resumed replay (skill cards first, prompt last). - // Marking only happens once the submission was accepted: a rejected - // bundle leaves no cards and must not leave a local undo anchor the - // engine never recorded. - for (const entry of this.state.transcriptEntries) { - if (entry.kind === 'skill_activation' && !knownEntryIds.has(entry.id)) { - entry.bundledWithPrompt = true; - } + : text; + } catch (error) { + if (prepared !== undefined) this.releaseSkillArgumentMedia(prepared); + this.staging.releaseMedia([ + ...extraction.imageAttachmentIds, ...extraction.videoAttachmentIds, + ], []); + this.failSessionRequest(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; } + const stagingLease = this.staging.create( + [ + ...new Set([...extraction.imageAttachmentIds, ...extraction.videoAttachmentIds]), + ...prepared.mediaAttachmentIds, + ], + prepared.stagingPaths, + 'user', + ); this.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'user', turnId: undefined, renderMode: 'plain', content: text, + skillNames: activations.map((activation) => activation.skillName), imageAttachmentIds: extraction.imageAttachmentIds.length > 0 ? extraction.imageAttachmentIds : undefined, }); + const request = session.promptWithSkills(input, prepared.skills); + this.staging.trackDispatch(stagingLease, request, (error) => { + if (this.session === session && stagingLease !== undefined && stagingLease.turnId === undefined) { + this.staging.defer(stagingLease); + for (const id of stagingLease.mediaAttachmentIds) this.staging.releaseRecalled([id]); + this.staging.releaseMedia([], stagingLease.paths); + } + this.handleSkillActivationError(session, error); + }); + } + + private handleSkillActivationError(session: Session, error: unknown): void { + if (this.session !== session) return; + const message = `Skill activation failed: ${formatErrorMessage(error)}`; + if (this.streamingUI.getTurnContext().turnId === undefined) this.failSessionRequest(message); + else this.showError(message); } validateMediaCapabilities(extraction: { @@ -1587,6 +1721,7 @@ export class PythinkerTUI { imageAttachmentIds: readonly number[]; videoAttachmentIds: readonly number[]; imageSnapshots?: readonly unknown[]; + mediaSnapshots?: readonly MediaAttachment[]; }): boolean { if (!extraction.hasMedia) return true; if ( @@ -1597,7 +1732,8 @@ export class PythinkerTUI { return false; } if ( - extraction.videoAttachmentIds.length > 0 && + (extraction.videoAttachmentIds.length > 0 || + extraction.mediaSnapshots?.some((snapshot) => snapshot.kind === 'video')) && !this.supportsCurrentModelCapability('video_in') ) { this.showError('Current model does not support video input.'); @@ -1704,6 +1840,7 @@ export class PythinkerTUI { ? options.videoAttachmentIds : undefined, mode, + mediaSnapshots: options?.mediaSnapshots, inlineSkillActivations: options?.inlineSkillActivations, }; } @@ -1738,12 +1875,6 @@ export class PythinkerTUI { void this.runShellCommandFromInput(item.text); return; } - if (item.mode === 'skill' && item.skillName !== undefined) { - // sendSkillActivation re-checks the busy state, so a premature drain - // re-queues at the tail instead of racing the running turn. - this.sendSkillActivation(session, item.skillName, item.skillArgs ?? ''); - return; - } if (item.inlineSkillActivations !== undefined && item.inlineSkillActivations.length > 0) { // Media was extracted and validated at enqueue time; reuse the queued // parts rather than re-extracting from a possibly-cleared image store. @@ -1758,21 +1889,16 @@ export class PythinkerTUI { this.imageStore, ), ]; + const extraction: ExtractionResult = { + parts: refreshed, + hasMedia: refreshed.length > 0, + imageAttachmentIds: item.imageAttachmentIds !== undefined ? [...item.imageAttachmentIds] : [], + videoAttachmentIds: item.videoAttachmentIds !== undefined ? [...item.videoAttachmentIds] : [], + imageSnapshots: [], + mediaSnapshots: item.mediaSnapshots, + }; this.beginSessionRequest(); - void this.runInlineSkillActivations( - session, - item.text, - item.inlineSkillActivations, - { - parts: refreshed, - hasMedia: refreshed.length > 0, - imageAttachmentIds: item.imageAttachmentIds !== undefined ? [...item.imageAttachmentIds] : [], - videoAttachmentIds: item.videoAttachmentIds !== undefined ? [...item.videoAttachmentIds] : [], - imageSnapshots: [], - }, - ).catch((error: unknown) => { - this.failSessionRequest(`Skill activation failed: ${formatErrorMessage(error)}`); - }); + this.runInlineSkillActivations(session, item.text, item.inlineSkillActivations, extraction); return; } const parts = @@ -1800,6 +1926,7 @@ export class PythinkerTUI { handleTurnEnded(event: TurnEndedEvent): void { this.staging.handleTurnEnded(event); this.surveyController.notifyTurnEnded(event.traceId); + this.state.footer.refreshGitStatus(); } releaseStagingMedia(mediaAttachmentIds: readonly number[]): void { @@ -1891,76 +2018,12 @@ export class PythinkerTUI { }); } - sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { - // Args are a plain-text channel, so pasted media can't ride along as - // inline parts. Skill args are XML-escaped on render (renderSkillAttributes - // + expandSkillParameters), so rewrite placeholders into escape-proof - // plain-text file references the model can open with ReadMediaFile. - let rewrite: ReturnType; - try { - rewrite = rewriteMediaPlaceholders(skillArgs, this.imageStore, 'plain'); - } catch (error) { - // Cache copy failed (unwritable cache dir, vanished video source…); - // nothing has been dispatched yet, so just report and keep the input. - this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); - return; - } - if (!this.validateMediaCapabilities(rewrite)) { - this.staging.releaseMedia(rewrite.imageAttachmentIds, rewrite.stagingPaths); - return; - } - // Compacting (or deferred input): queue behind it — visible and recallable. - // Slash-skill items steer like any queued input on Ctrl-S (the activation - // fires into the running turn instead of the literal text) — see - // editor-keyboard.ts. - // A running turn queues the activation too: every skill behaves like - // plain input — queued by default, steered on demand — because the engine - // steers activations into a running turn exactly like a steered user - // message (v2 `prompt.inject`, v1 `SkillManager.recordActivation`). - // The rewritten args reference the staging cache copies by plain path, - // never the daemon uploads, so queueing takes recall semantics: the - // retains are consumed and the copies retire to session lifetime — they - // must stay readable until the item drains. - const turnRunning = this.state.appState.streamingPhase !== 'idle'; - if (this.deferUserMessages || this.state.appState.isCompacting || turnRunning) { - const args = rewrite.text.trim(); - this.state.queuedMessages.push({ - text: `/${skillName}${args.length > 0 ? ` ${args}` : ''}`, - agentId: this.harness.interactiveAgentId, - mode: 'skill', - skillName, - skillArgs: rewrite.text, - }); - this.staging.releaseRecalled([...rewrite.imageAttachmentIds], rewrite.stagingPaths); - this.track('input_queue'); - this.updateQueueDisplay(); - this.state.ui.requestRender(); - return; - } - const stagingLease = this.staging.create( - [...new Set(rewrite.imageAttachmentIds)], - rewrite.stagingPaths, - 'skill_activation', - ); - this.beginSessionRequest(); - this.staging.trackDispatch( - stagingLease, - session.activateSkill(skillName, rewrite.text), - (error) => { - this.failSessionRequest(`Skill "${skillName}" failed: ${formatErrorMessage(error)}`); - }, - ); - } - activatePluginCommand( session: Session, pluginId: string, commandName: string, args: string, ): void { - // Plugin command args are expanded verbatim (no XML escaping), so the - // standard tag convention works — see - // sendSkillActivation for the escaped-channel variant. let rewrite: ReturnType; try { rewrite = rewriteMediaPlaceholders(args, this.imageStore, 'tag'); @@ -2011,6 +2074,7 @@ export class PythinkerTUI { } if ( this.deferUserMessages || + this.steeringQueuedMessages.size > 0 || this.state.appState.streamingPhase !== 'idle' || this.state.appState.isCompacting ) { @@ -2042,52 +2106,76 @@ export class PythinkerTUI { ); } - /** Steers the whole queue into the running turn when it is prompt-only. - * The steered items stay at the front of the queue while the steer is in - * flight, and the queue holds (see `shiftQueuedMessage`) so nothing queued - * behind them dispatches first: success removes them, failure leaves them - * queued in place, and a queue left behind by an ended turn drains once - * the steer settles. */ - steerQueuedMessagesIntoRunningTurn(): void { + steerQueuedMessagesIntoRunningTurn(manual = false): void { const session = this.session; if (session === undefined || this.steeringQueuedMessages.size > 0) return; - if (!this.canSteerQueueIntoRunningTurn()) return; - const batch = [...this.state.queuedMessages]; - if (batch.length === 0 || !batch.every(isSteerableQueuedMessage)) return; - for (const message of batch) this.steeringQueuedMessages.add(message); - this.updateQueueDisplay(); - // Same expiring-upload refresh as the queue drain (`sendQueuedMessage`): - // an image whose daemon upload expired falls back to its retained bytes. - const items = batch.map((message) => { - const item = toSteerInputItem(message); - if (message.parts === undefined) return item; - return { - ...item, - parts: refreshExpiringImageFileRefs( - message.parts, - message.imageAttachmentIds ?? [], - this.imageStore, - ), - }; - }); - this.steerMessage(session, items, (steered) => { - for (const message of batch) this.steeringQueuedMessages.delete(message); - if (this.session !== session) return; - if (steered) { - const done = new Set(batch); - this.state.queuedMessages = this.state.queuedMessages.filter((m) => !done.has(m)); - } - this.updateQueueDisplay(); - if (steered && this.canSteerQueueIntoRunningTurn()) { - this.steerQueuedMessagesIntoRunningTurn(); + if (manual ? !this.canManuallySteer() : !this.canSteerQueueIntoRunningTurn()) return; + const batch = manual + ? this.state.queuedMessages.filter((message) => message.mode !== 'bash') + : [...this.state.queuedMessages]; + if (batch.length === 0 || (!manual && !batch.every(isSteerableQueuedMessage))) return; + const pending = [...batch]; + const sendNext = (): void => { + if (this.deferUserMessages || this.state.appState.isCompacting) return; + let first = pending.shift(); + while (first !== undefined && !this.state.queuedMessages.includes(first)) first = pending.shift(); + if (first === undefined) { + this.updateQueueDisplay(); + if (!manual && this.canSteerQueueIntoRunningTurn()) { + this.steerQueuedMessagesIntoRunningTurn(); + } else { + this.drainQueueIfIdle(); + } return; } - // A turn that ended while the steer was in flight could not drain the - // held queue. A prompt dispatched now while the engine still runs a - // turn launched by the steer is queued behind it by the engine, so the - // order holds either way. - this.drainQueueIfIdle(); - }); + const group = [first]; + if (first.inlineSkillActivations === undefined) { + while (pending[0] !== undefined && pending[0].inlineSkillActivations === undefined) { + const next = pending.shift()!; + if (this.state.queuedMessages.includes(next)) group.push(next); + } + } + for (const message of group) this.steeringQueuedMessages.add(message); + this.updateQueueDisplay(); + const items = group.map((message) => { + const item = toSteerInputItem(message); + if (message.parts === undefined) return item; + return { + ...item, + parts: refreshExpiringImageFileRefs( + message.parts, + message.imageAttachmentIds ?? [], + this.imageStore, + ), + }; + }); + this.steerMessage(session, items, (steered) => { + for (const message of group) this.steeringQueuedMessages.delete(message); + if (this.session !== session) return; + if (steered || manual) { + const done = new Set(group); + this.state.queuedMessages = this.state.queuedMessages.filter((message) => !done.has(message)); + } + if (!steered) { + this.staging.releaseQueued(group.filter((message) => !this.state.queuedMessages.includes(message))); + } + this.updateQueueDisplay(); + if (steered || manual) sendNext(); + else this.drainQueueIfIdle(); + }, manual); + }; + sendNext(); + } + + canManuallySteer(): boolean { + const phase = this.state.appState.streamingPhase; + return ( + !this.deferUserMessages && + !this.state.appState.isCompacting && + phase !== 'idle' && + phase !== 'shell' && + this.steeringQueuedMessages.size === 0 + ); } isSteeringQueuedMessages(): boolean { @@ -2106,70 +2194,89 @@ export class PythinkerTUI { this.drainOneQueuedMessage(); } - /** `onSettled`, when given, runs once the steer settles. On a rejection its - * staged media is first handed back to raw ownership and the user entries - * added for it are removed, so the caller can keep the input queued - * instead of losing it behind an error; a steer from a session that has - * since been replaced is dropped with that session. */ steerMessage( session: Session, input: readonly SteerInputItem[], onSettled?: (steered: boolean) => void, + manual = false, ): void { if (this.deferUserMessages || this.state.appState.isCompacting) { - for (const item of input) { - this.enqueueMessage(item.text, item); + if (onSettled !== undefined) { + onSettled(false); + } else { + for (const item of input) this.enqueueMessage(item.text, item); } return; } - if (this.state.appState.streamingPhase === 'idle') { - for (const item of input) { - this.sendMessageInternal(session, item.text, item); + const activations = input.flatMap((item) => item.inlineSkillActivations ?? []); + let prepared: PreparedSkillActivations | undefined; + let resolvedInput: readonly SteerInputItem[]; + try { + if (activations.length > 0) { + prepared = this.prepareSkillActivations( + activations, + input.some((item) => item.mediaSnapshots !== undefined) + ? input.flatMap((item) => item.mediaSnapshots ?? []) + : undefined, + ); } + resolvedInput = input.map((item) => + item.parts === undefined + ? item + : { + ...item, + parts: resolveOriginalCaptions( + prepared === undefined ? item.parts : omitExpiredVideoFileRefs(item.parts, prepared.cachedVideos), + item.imageAttachmentIds ?? [], + this.imageStore, + originalsDirForSession(session), + ), + }, + ); + } catch (error) { + if (prepared !== undefined) this.releaseSkillArgumentMedia(prepared); + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + onSettled?.(false); return; } - - const steeredEntries: TranscriptEntry[] = []; - for (const item of input) { - const entry: TranscriptEntry = { - id: nextTranscriptId(), - kind: 'user', - turnId: this.streamingUI.getTurnContext().turnId, - renderMode: 'plain', - content: item.text, - imageAttachmentIds: - item.imageAttachmentIds !== undefined && item.imageAttachmentIds.length > 0 - ? item.imageAttachmentIds - : undefined, - }; - steeredEntries.push(entry); - this.appendTranscriptEntry(entry); - } - - // Dedupe per item, not across the batch: each queued message retained a - // shared medium once, so the batch's id multiplicity is the retain count. + const startsNewTurn = this.state.appState.streamingPhase === 'idle'; + if (startsNewTurn) this.beginSessionRequest(); + const steeredEntries: TranscriptEntry[] = input.map((item) => ({ + id: nextTranscriptId(), + kind: 'user', + turnId: this.streamingUI.getTurnContext().turnId, + renderMode: 'plain', + content: item.text, + skillNames: item.inlineSkillActivations?.map((activation) => activation.skillName), + imageAttachmentIds: + item.imageAttachmentIds !== undefined && item.imageAttachmentIds.length > 0 + ? item.imageAttachmentIds + : undefined, + })); + for (const entry of steeredEntries) this.appendTranscriptEntry(entry); const mediaAttachmentIds = input.flatMap((item) => [ ...new Set([...(item.imageAttachmentIds ?? []), ...(item.videoAttachmentIds ?? [])]), ]); - const stagingLease = this.staging.create(mediaAttachmentIds, [], 'user'); - const currentTurnId = this.streamingUI.getTurnContext().turnId; - if (currentTurnId !== undefined) this.staging.bindToTurn(stagingLease, currentTurnId); - // Same dispatch-time caption resolution as sendMessageInternal — the - // running turn's session owns the persisted originals. - const resolvedInput = input.map((item) => - item.parts === undefined - ? item - : { - ...item, - parts: resolveOriginalCaptions( - item.parts, - item.imageAttachmentIds ?? [], - this.imageStore, - originalsDirForSession(session), - ), - }, + const stagingLease = this.staging.create( + [...mediaAttachmentIds, ...(prepared?.mediaAttachmentIds ?? [])], + prepared?.stagingPaths ?? [], + 'user', ); - const request = session.steer(combineSteerInput(resolvedInput)); + const currentTurnId = this.streamingUI.getTurnContext().turnId; + // Skill steers may not merge into the current turn: an untracked active + // turn (e.g. a goal continuation) has no steerable prompt, so the engine + // queues the submission — and binding the lease here would release its + // media when that unrelated turn ends. Leave skill leases unclaimed so + // the submission's own turn.started claims them; a merged steer simply + // retires its media at session close instead. + if (currentTurnId !== undefined && prepared === undefined) this.staging.bindToTurn(stagingLease, currentTurnId); + const request = prepared === undefined + ? session.steer(combineSteerInput(resolvedInput)) + : session.promptWithSkills( + combineSteerInput(resolvedInput), + prepared.skills, + { steerIfActive: true }, + ); if (onSettled !== undefined) { void request.then( () => onSettled(true), @@ -2177,16 +2284,22 @@ export class PythinkerTUI { ); } this.staging.trackDispatch(stagingLease, request, (error) => { + if (this.session !== session) { + onSettled?.(false); + return; + } + const message = `Failed to steer: ${formatErrorMessage(error)}`; + if (startsNewTurn && this.streamingUI.getTurnContext().turnId === undefined) { + this.failSessionRequest(message); + } else { + this.showError(message); + } if (onSettled !== undefined) { - if (this.session !== session) { - onSettled(false); - return; - } this.staging.defer(stagingLease); - this.removeTranscriptEntries(steeredEntries); + if (prepared !== undefined) this.releaseSkillArgumentMedia(prepared); + if (prepared === undefined && !manual) this.removeTranscriptEntries(steeredEntries); onSettled(false); } - this.showError(`Failed to steer: ${formatErrorMessage(error)}`); }); } @@ -2206,15 +2319,6 @@ export class PythinkerTUI { this.state.ui.requestRender(); } - steerSkillActivation(session: Session, skillName: string, skillArgs: string): void { - // Ctrl-S on a queued slash-skill item: the activation fires into the - // running turn (the engine steers it there, never the literal text). No - // beginSessionRequest — the live pane belongs to the running turn. - void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { - this.showError(`Skill "${skillName}" failed: ${formatErrorMessage(error)}`); - }); - } - // ========================================================================= // State & Accessors // ========================================================================= @@ -2226,15 +2330,12 @@ export class PythinkerTUI { clearQueuedMessages(): void { const queued = this.state.queuedMessages; this.state.queuedMessages = []; - this.staging.releaseQueued(queued); + this.staging.releaseQueued(queued.filter((message) => !this.steeringQueuedMessages.has(message))); } shiftQueuedMessage(): QueuedMessage | undefined { - if (this.state.queuedMessages.length === 0) return undefined; - const [first, ...rest] = this.state.queuedMessages; - if (this.steeringQueuedMessages.has(first!)) return undefined; - this.state.queuedMessages = rest; - return first; + if (this.steeringQueuedMessages.size > 0) return undefined; + return this.state.queuedMessages.shift(); } pushTranscriptEntry(entry: TranscriptEntry): void { @@ -2243,6 +2344,7 @@ export class PythinkerTUI { setExternalEditorRunning(running: boolean): void { this.state.externalEditorRunning = running; + if (!running) this.modelMigration.retry(); } setTasksBrowser(value: TUIState['tasksBrowser']): void { @@ -2299,6 +2401,7 @@ export class PythinkerTUI { this.sessionEventHandler.retryQueuedGoalPromotion(); } if (additionalDirsChanged) this.setupAutocomplete(); + this.modelMigration.retry(); this.state.ui.requestRender(); } @@ -2343,21 +2446,10 @@ export class PythinkerTUI { async hydrateLazyConfigDefaults(): Promise { const { startup } = this.options; const config = await this.harness.getConfig({ reload: true }); - const patch: Partial = {}; - const startupModel = startup.model ?? config.defaultModel; - if (startupModel !== undefined) { - patch.model = startupModel; - const selected = config.models?.[startupModel]; - if (selected?.maxContextSize !== undefined) { - patch.maxContextTokens = selected.maxContextSize; - } - } else { - // The default disappeared from config (edited externally): clear the - // previously hydrated value instead of passing a stale explicit model - // to the first lazy-created session. - patch.model = ''; - patch.maxContextTokens = 0; - } + const patch: Partial = lazyModelSelection( + config, + startup.model ?? config.defaultModel, + ); // CLI --auto/--yolo/--plan win over config defaults; the flags are // re-applied by applyStartupPermissionAndPlanToAppState at startup. if (!startup.auto && !startup.yolo) { @@ -2372,20 +2464,6 @@ export class PythinkerTUI { if (!startup.plan) { patch.planMode = config.defaultPlanMode === true; } - const effort = thinkingEffortFromConfig(config.thinking); - if (effort !== undefined) { - patch.thinkingEffort = effort; - } else if (startupModel !== undefined) { - // No concrete effort configured: mirror the engine, which resolves the - // model's default effort at createSession time. - const raw = config.models?.[startupModel]; - if (raw !== undefined) { - const providerType = config.providers?.[raw.provider]?.type; - patch.thinkingEffort = defaultThinkingEffortFor( - effectiveModelAlias(raw, providerType ?? raw.protocol), - ); - } - } if (startup.agentProfile !== undefined || startup.agentFiles !== undefined) { patch.agentProfile = startup.agentProfile; patch.agentFiles = startup.agentFiles?.length ? [...startup.agentFiles] : undefined; @@ -2466,9 +2544,15 @@ export class PythinkerTUI { return this.ensureSessionPromise; } - /** Await the in-flight lazy session creation, if any (v2); no-op otherwise. */ - async waitForLazyCreation(): Promise { + /** Await in-flight session assembly — lazy creation or a `/new` replacement — if any; no-op otherwise. */ + async waitForSessionAssembly(): Promise { await this.ensureSessionPromise; + await this.newSessionPromise; + } + + /** True while a `/new` replacement is assembling; idle checks must treat it as busy. */ + get sessionReplacementInFlight(): boolean { + return this.newSessionPromise !== null; } private async lazyCreateSession(): Promise { @@ -2481,7 +2565,7 @@ export class PythinkerTUI { return undefined; } this.resetSessionRuntime(); - await this.setSession(session); + await this.setSession(session, 0); this.setAppState({ sessionId: session.id }); try { await this.activateRuntime(); @@ -2508,7 +2592,10 @@ export class PythinkerTUI { return session; } - async setSession(session: Session): Promise { + async setSession( + session: Session, + initialUserTurnCount = session.getResumeState()?.agents[MAIN_AGENT_ID]?.userTurnCount, + ): Promise { const previous = this.unloadCurrentSession('switching session'); await previous?.close(); // A session switch abandons the previous session's in-flight staging @@ -2521,6 +2608,7 @@ export class PythinkerTUI { // before the engine's intake can read it. if (previous !== undefined) this.staging.releaseAll(); this.session = session; + this.surveyController.seedUserTurnCount(initialUserTurnCount); this.harness.setTelemetryContext({ sessionId: session.id }); this.registerSessionHandlers(session); this.syncAdditionalDirs(session); @@ -2771,7 +2859,7 @@ export class PythinkerTUI { // A first-use lazy creation may still be in flight: wait it out so the // checks below see settled state — the pending prompt would otherwise // replace the resumed session when creation completes. - await this.waitForLazyCreation(); + await this.waitForSessionAssembly(); if (targetSessionId === this.state.appState.sessionId) { this.showStatus('Already on this session.'); return true; @@ -2842,6 +2930,9 @@ export class PythinkerTUI { this.resetSessionRuntime(); this.session = session; + this.surveyController.seedUserTurnCount( + session.getResumeState()?.agents[MAIN_AGENT_ID]?.userTurnCount, + ); this.harness.setTelemetryContext({ sessionId: session.id }); this.registerSessionHandlers(session); await this.syncRuntimeState(session); @@ -2867,7 +2958,19 @@ export class PythinkerTUI { this.showError('Cannot start a new session while history is replaying.'); return; } + // Track the replacement like `ensureSession` tracks lazy creation: a + // second `/new` shares the in-flight assembly instead of racing a second + // createSession, and idle checks (the model-migration prompt) treat the + // switch as busy until it has fully settled. + if (this.newSessionPromise !== null) return this.newSessionPromise; + this.newSessionPromise = this.assembleNewSession().finally(() => { + this.newSessionPromise = null; + this.modelMigration.retry(); + }); + return this.newSessionPromise; + } + private async assembleNewSession(): Promise { let session: Session; try { session = await this.createSessionFromCurrentState(); @@ -2878,7 +2981,7 @@ export class PythinkerTUI { } this.resetSessionRuntime(); - await this.setSession(session); + await this.setSession(session, 0); this.setAppState({ sessionId: session.id }); try { await this.activateRuntime(); @@ -2938,7 +3041,11 @@ export class PythinkerTUI { const images = entry.imageAttachmentIds ?.map((id) => this.imageStore.get(id)) .filter((a): a is ImageAttachment => a?.kind === 'image'); - return new UserMessageComponent(entry.content, images, entry.bullet); + const skillNames = new Set(entry.skillNames); + for (const [command, skillName] of this.skillCommandMap) { + if (entry.skillNames?.includes(skillName)) skillNames.add(command.replace(/^skill:/, '')); + } + return new UserMessageComponent(entry.content, images, entry.bullet, [...skillNames]); } case 'skill_activation': return new SkillActivationComponent( @@ -3019,6 +3126,32 @@ export class PythinkerTUI { } } + /** + * Relocate an already-mounted transcript entry (and its component) to the + * end of the transcript. Used to re-anchor an asynchronously mounted + * background-task terminal card to the start of its notification turn, so + * the card's fold-segment boundary guards that turn's output instead of + * splitting whatever turn happened to be live when the task terminated. + * Returns false when the entry is no longer mounted (e.g. trimmed). + */ + moveTranscriptEntryToEnd(entry: TranscriptEntry): boolean { + const entries = this.state.transcriptEntries; + const entryIndex = entries.indexOf(entry); + if (entryIndex < 0) return false; + entries.splice(entryIndex, 1); + entries.push(entry); + const children = this.state.transcriptContainer.children; + const childIndex = children.findIndex( + (child) => getTranscriptComponentEntry(child) === entry, + ); + if (childIndex >= 0) { + const [component] = children.splice(childIndex, 1); + children.push(component!); + } + this.state.ui.requestRender(); + return true; + } + private appendApprovalTranscriptEntry( request: ApprovalRequest, response: ApprovalResponse, @@ -3121,13 +3254,22 @@ export class PythinkerTUI { /** * Fold-segment boundary: everything {@link isTurnBoundaryComponent} counts, - * plus the cron card. A cron-fired turn mounts no user message, so without - * the card as a boundary its output would share the previous user turn's + * plus the cron card and terminal background-task cards. Cron-fired and + * task-notification turns mount no user message, so without one of these + * cards as a boundary their output would share the previous user turn's * fold segment — and the completed-turn assistant cap would fold that turn's - * final answer into the step summary. + * final answer into the step summary. A task-notification turn re-anchors + * its terminal card to the turn's start (see SessionEventHandler), because + * the card's asynchronous mount point may sit inside an unrelated turn; + * cards that never get a notification turn (resumed sessions, missed + * drains) still bound whatever segment they landed in. */ private isFoldSegmentBoundaryComponent(child: Component): boolean { - return this.isTurnBoundaryComponent(child) || child instanceof CronMessageComponent; + return ( + this.isTurnBoundaryComponent(child) || + child instanceof CronMessageComponent || + (child instanceof BackgroundAgentStatusComponent && child.phase !== 'started') + ); } private trimTranscriptWindow(): boolean { @@ -3511,7 +3653,7 @@ export class PythinkerTUI { break; } case 'thinking': { - const spinner = this.ensureActivitySpinner('Thinking…', (s) => + const spinner = this.ensureActivitySpinner('Working…', (s) => currentTheme.fg('primary', s), ); this.syncAgentDynamicWorkflowActivitySpinner(undefined); @@ -3920,20 +4062,27 @@ export class PythinkerTUI { // Dialogs / Selectors // ========================================================================= - mountEditorReplacement(panel: Component & Focusable): void { + mountEditorReplacement(panel: Component & Focusable, onDisplaced?: () => void): void { + const previousOnDisplaced = this.editorReplacementOnDisplaced; + this.editorReplacementOnDisplaced = onDisplaced; this.surveyController.notifyDisplaced(); this.state.editorReplacementMounted = true; this.state.editorContainer.clear(); this.state.editorContainer.addChild(panel); this.state.ui.setFocus(panel); this.state.ui.requestRender(); + previousOnDisplaced?.(); } restoreEditor(): void { + const onDisplaced = this.editorReplacementOnDisplaced; + this.editorReplacementOnDisplaced = undefined; this.state.editorReplacementMounted = false; this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); + onDisplaced?.(); + this.modelMigration.retry(); // Differential render only: closing a tall panel leaves the editor a few // rows above the bottom (blank tail) until the next append, but avoids a // destructive full redraw on every dialog close. @@ -4154,7 +4303,7 @@ export class PythinkerTUI { // that is about to lock itself for the delete. this.sessionPickerScopeRequestToken += 1; try { - await this.waitForLazyCreation(); + await this.waitForSessionAssembly(); if (session.id === this.state.appState.sessionId && this.session !== undefined) { await this.deleteCurrentSessionFromPicker(session); return; @@ -4372,8 +4521,7 @@ export class PythinkerTUI { function isSteerableQueuedMessage(message: QueuedMessage): boolean { return ( message.inlineSkillActivations === undefined && - message.mode !== 'bash' && - message.mode !== 'skill' + message.mode !== 'bash' ); } @@ -4383,6 +4531,8 @@ function toSteerInputItem(message: QueuedMessage): SteerInputItem { parts: message.parts, imageAttachmentIds: message.imageAttachmentIds, videoAttachmentIds: message.videoAttachmentIds, + mediaSnapshots: message.mediaSnapshots, + inlineSkillActivations: message.inlineSkillActivations, }; } diff --git a/apps/pythinker-code/src/tui/types.ts b/apps/pythinker-code/src/tui/types.ts index f796dd43c..15213d756 100644 --- a/apps/pythinker-code/src/tui/types.ts +++ b/apps/pythinker-code/src/tui/types.ts @@ -13,6 +13,7 @@ import type { import type { MarkdownConfig, NotificationsConfig, StatusLineConfig, TuiMode, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; +import type { MediaAttachment, VideoAttachment } from './utils/image-attachment-store'; export type BannerDisplay = 'always' | 'once' | 'cooldown'; @@ -164,18 +165,25 @@ export interface BackgroundAgentMetadata { readonly parentToolCallId: string; readonly agentName?: string; readonly description?: string; + /** Background task id carried on the spawn event (Agent tool only); lets the + * terminal card register before `background.task.started` syncs. */ + readonly taskId?: string; /** Display name of the model the agent is bound to (resolved at spawn). */ readonly model?: string; /** Thinking effort, set only for concrete levels (boolean on/off hidden). */ readonly effort?: string; } -export type BackgroundAgentStatusPhase = 'started' | 'completed' | 'failed'; +export type BackgroundAgentStatusPhase = 'started' | 'completed' | 'failed' | 'killed'; export interface BackgroundAgentStatusData { readonly phase: BackgroundAgentStatusPhase; readonly headline: string; readonly detail?: string; + /** Subagent behind a background-agent card; lets the task-notification + * anchor reuse a terminal card that was mounted before its task id was + * known (e.g. a tower worker that finished before `registerTask`). */ + readonly agentId?: string; } export interface CompactionTranscriptData { @@ -244,6 +252,7 @@ export interface TranscriptEntry { cronData?: CronTranscriptData; goalData?: GoalTranscriptData; imageAttachmentIds?: readonly number[]; + skillNames?: readonly string[]; skillActivationId?: string; skillName?: string; skillArgs?: string; @@ -268,12 +277,15 @@ export interface LivePaneState { pendingQuestion: PendingQuestion | null; } +export interface PreparedSkillActivations { + skills: Array<{ name: string; args?: string }>; + mediaAttachmentIds: number[]; + stagingPaths: string[]; + cachedVideos: VideoAttachment[]; +} + export interface InlineSkillActivation { readonly skillName: string; - /** - * Skill arguments. Only set for a leading `/skill: args` command that - * is combined with further inline skills; inline tokens carry no args. - */ readonly args?: string; } @@ -283,15 +295,8 @@ export interface QueuedMessage { readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; readonly videoAttachmentIds?: readonly number[]; - /** `bash` for a `!` shell command queued while another command is running; - * `skill` for a slash-skill activation queued while the session is busy; - * undefined (=`prompt`) for a normal message. */ - readonly mode?: 'prompt' | 'bash' | 'skill'; - /** Set when mode === 'skill': the skill to activate when the item drains. - * `text` then holds the display/recall string (`/name args`). */ - readonly skillName?: string; - /** Set when mode === 'skill': the raw (media-rewritten) args to activate with. */ - readonly skillArgs?: string; + readonly mediaSnapshots?: readonly MediaAttachment[]; + readonly mode?: 'prompt' | 'bash'; /** Skills to activate together with this queued message's prompt. */ readonly inlineSkillActivations?: readonly InlineSkillActivation[]; } @@ -307,6 +312,8 @@ export interface SteerInputItem { readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; readonly videoAttachmentIds?: readonly number[]; + readonly mediaSnapshots?: readonly MediaAttachment[]; + readonly inlineSkillActivations?: readonly InlineSkillActivation[]; } export const INITIAL_LIVE_PANE: LivePaneState = { diff --git a/apps/pythinker-code/src/tui/utils/background-agent-status.ts b/apps/pythinker-code/src/tui/utils/background-agent-status.ts index c2322e633..eb13c85db 100644 --- a/apps/pythinker-code/src/tui/utils/background-agent-status.ts +++ b/apps/pythinker-code/src/tui/utils/background-agent-status.ts @@ -26,7 +26,9 @@ export function formatBackgroundAgentTranscript( ? `${subject} started in background` : phase === 'completed' ? `${subject} completed in background` - : `${subject} failed in background`; + : phase === 'killed' + ? `${subject} stopped` + : `${subject} failed in background`; const tail = phase === 'failed' ? normalizeBackgroundField(extras?.error) : undefined; const detailParts = [ normalizeBackgroundField(meta.model), @@ -39,5 +41,6 @@ export function formatBackgroundAgentTranscript( phase, headline, detail: detailParts.length > 0 ? detailParts.join(' · ') : undefined, + agentId: meta.agentId, }; } diff --git a/apps/pythinker-code/src/tui/utils/image-attachment-store.ts b/apps/pythinker-code/src/tui/utils/image-attachment-store.ts index 54d363a8e..92452f104 100644 --- a/apps/pythinker-code/src/tui/utils/image-attachment-store.ts +++ b/apps/pythinker-code/src/tui/utils/image-attachment-store.ts @@ -110,9 +110,14 @@ type MutableImageAttachment = { export class ImageAttachmentStore { private nextId = 1; + private currentGeneration = 0; private readonly byId = new Map(); private readonly stagingUses = new Map(); + get generation(): number { + return this.currentGeneration; + } + addImage( bytes: Uint8Array, mime: string, @@ -242,6 +247,7 @@ export class ImageAttachmentStore { this.byId.clear(); this.stagingUses.clear(); this.nextId = 1; + this.currentGeneration += 1; return fileIds; } diff --git a/apps/pythinker-code/src/tui/utils/image-placeholder.ts b/apps/pythinker-code/src/tui/utils/image-placeholder.ts index f93cc44d1..d307712c9 100644 --- a/apps/pythinker-code/src/tui/utils/image-placeholder.ts +++ b/apps/pythinker-code/src/tui/utils/image-placeholder.ts @@ -57,6 +57,7 @@ import { MEDIA_FILE_REF_MIN_REMAINING_MS } from '../constant/media'; import type { ImageAttachment, ImageAttachmentStore, + MediaAttachment, VideoAttachment, } from './image-attachment-store'; @@ -80,6 +81,8 @@ export interface ExtractionResult { * snapshots to rebuild the image parts as inline data URLs. */ imageSnapshots: ImageResendSnapshot[]; + mediaSnapshots?: readonly MediaAttachment[]; + mediaGeneration?: number; } export interface ImageResendSnapshot { @@ -109,6 +112,7 @@ export function extractMediaAttachments( const imageAttachmentIds: number[] = []; const videoAttachmentIds: number[] = []; const imageSnapshots: ImageResendSnapshot[] = []; + const mediaSnapshots: MediaAttachment[] = []; let cursor = 0; let hasMedia = false; @@ -122,6 +126,7 @@ export function extractMediaAttachments( const attachment = store.get(id); if (attachment === undefined) continue; // stale / user-typed — leave as text if (attachment.kind !== kind) continue; + mediaSnapshots.push(attachment); const before = text.slice(cursor, match.index); pushText(parts, before); if (attachment.kind === 'video') { @@ -184,6 +189,8 @@ export function extractMediaAttachments( imageAttachmentIds, videoAttachmentIds, imageSnapshots, + mediaSnapshots: mediaSnapshots.length > 0 ? mediaSnapshots : undefined, + mediaGeneration: store.generation, }; } @@ -349,6 +356,7 @@ export interface MediaTagRewriteResult { imageAttachmentIds: number[]; videoAttachmentIds: number[]; stagingPaths: string[]; + cachedVideos: VideoAttachment[]; } /** @@ -377,10 +385,12 @@ export function rewriteMediaPlaceholders( text: string, store: ImageAttachmentStore, style: MediaReferenceStyle = 'tag', + mediaSnapshots?: readonly MediaAttachment[], ): MediaTagRewriteResult { const imageAttachmentIds: number[] = []; const videoAttachmentIds: number[] = []; const stagingPaths: string[] = []; + const cachedVideos: VideoAttachment[] = []; let cursor = 0; let out = ''; @@ -392,8 +402,10 @@ export function rewriteMediaPlaceholders( if (kind !== 'image' && kind !== 'video') continue; if (idStr === undefined) continue; const id = Number.parseInt(idStr, 10); - const attachment = store.get(id); - if (attachment === undefined) continue; // stale / user-typed — leave as text + const attachment = mediaSnapshots === undefined + ? store.get(id) + : mediaSnapshots.find((snapshot) => snapshot.id === id); + if (attachment === undefined) continue; if (attachment.kind !== kind) continue; out += text.slice(cursor, match.index); if (attachment.kind === 'video') { @@ -403,7 +415,8 @@ export function rewriteMediaPlaceholders( style === 'plain' ? formatMediaReference('video', path) : buildMediaPathTag('video', path); - videoAttachmentIds.push(id); + cachedVideos.push(attachment); + if (store.get(id) === attachment) videoAttachmentIds.push(id); } else { const path = materializeImageToCache(attachment); stagingPaths.push(path); @@ -411,12 +424,12 @@ export function rewriteMediaPlaceholders( style === 'plain' ? formatMediaReference('image', path) : buildMediaPathTag('image', path); - imageAttachmentIds.push(id); + if (store.get(id) === attachment) imageAttachmentIds.push(id); } cursor = match.index + literal.length; } - const hasMedia = imageAttachmentIds.length + videoAttachmentIds.length > 0; + const hasMedia = stagingPaths.length > 0; store.retainFileIds(imageAttachmentIds); return { text: hasMedia ? out + text.slice(cursor) : text, @@ -424,6 +437,7 @@ export function rewriteMediaPlaceholders( imageAttachmentIds, videoAttachmentIds, stagingPaths, + cachedVideos, }; } catch (error) { cleanupStagingPaths(stagingPaths); @@ -431,6 +445,22 @@ export function rewriteMediaPlaceholders( } } +export function omitExpiredVideoFileRefs( + parts: readonly PromptPart[], + cachedVideos: readonly VideoAttachment[], +): PromptPart[] { + const expiredUrls = new Set( + cachedVideos.flatMap((attachment) => + attachment.fileId !== undefined && + attachment.fileExpiresAt !== undefined && + attachment.fileExpiresAt - Date.now() <= MEDIA_FILE_REF_MIN_REMAINING_MS + ? [buildDaemonFileUrl(attachment.fileId)] + : [], + ), + ); + return parts.filter((part) => part.type !== 'video_url' || !expiredUrls.has(part.videoUrl.url)); +} + function cleanupStagingPaths(paths: readonly string[]): void { for (const path of paths) { try { diff --git a/apps/pythinker-code/src/tui/utils/inline-skill-tokens.ts b/apps/pythinker-code/src/tui/utils/inline-skill-tokens.ts index 5444304a9..8f1e556da 100644 --- a/apps/pythinker-code/src/tui/utils/inline-skill-tokens.ts +++ b/apps/pythinker-code/src/tui/utils/inline-skill-tokens.ts @@ -1,14 +1,5 @@ -/** - * Scanner for inline skill `/tokens` inside a prompt. - * - * Dispatch, editor highlighting, and autocomplete share this so all three - * agree on what counts as an inline skill reference: a `/name` token whose `/` - * is preceded by whitespace (space, tab, or newline), with no internal `/`. - * The leading slash-command area at the very start of the input is handled by - * the regular slash-command path and is skipped here by default. - */ - import type { InlineSkillActivation } from '../types'; +import { parseSlashInput } from '../commands/parse'; export interface InlineSkillToken { readonly commandName: string; @@ -52,7 +43,7 @@ export function findInlineSkillTokens( } const commandName = text.slice(i + 1, end); - if (commandName.includes('/')) continue; + if (commandName.includes('/') && (!isLeadingSlash || parseSlashInput(text.slice(i, end)) === null)) continue; if (commandName.length === 0 && options.allowEmpty !== true) continue; if (!options.isKnownSkill(commandName)) continue; diff --git a/apps/pythinker-code/src/tui/utils/message-replay.ts b/apps/pythinker-code/src/tui/utils/message-replay.ts index c38e8dfdf..8fa35251d 100644 --- a/apps/pythinker-code/src/tui/utils/message-replay.ts +++ b/apps/pythinker-code/src/tui/utils/message-replay.ts @@ -53,6 +53,7 @@ export interface SkillActivationProjection { readonly activationId: string; readonly skillName: string; readonly skillArgs?: string; + readonly skillSource?: string; readonly trigger: SkillActivationTrigger; /** The activation rode a bundled prompt message, not a standalone one. */ readonly bundled?: boolean; @@ -322,6 +323,7 @@ export function skillActivationFromOrigin( activationId: origin.activationId, skillName: origin.skillName, skillArgs: origin.skillArgs, + skillSource: origin.skillSource, trigger: origin.trigger, }; } @@ -370,6 +372,35 @@ export function stripBundledSkillParts(message: ContextMessage): readonly Conten ).filter((part) => !isSkillActivationPart(part)); } +export function userSlashSkillPromptText( + message: ContextMessage, + skill: SkillActivationProjection, +): string { + const parts = withoutUserPromptSubmitHookParts(message.content); + const [first, ...rest] = parts; + const callerParts = first?.type === 'text' ? rest : parts; + const name = skill.skillSource === 'builtin' ? skill.skillName : `skill:${skill.skillName}`; + const args = skill.skillArgs ?? ''; + const command = args.trim().length > 0 ? `/${name} ${args}` : `/${name}`; + // Media parts mean the caller content came from an external activation with + // attachments (e.g. agent-gateway), never from a typed command: keep the + // command identity and show the attachment rendering alongside it. + if (callerParts.some((part) => part.type !== 'text')) { + const attachmentText = contentPartsToText(callerParts).trim(); + return attachmentText.length > 0 ? `${command} ${attachmentText}` : command; + } + const callerText = contentPartsToText(callerParts); + const trimmed = callerText.trim(); + if (trimmed.length === 0) return command; + // Text-only caller content is ambiguous: a saved typed command always + // starts with the slash mention, while external text attachments (the + // agent-gateway activation API accepts text attachments) are arbitrary prose + // — prepend the reconstructed command for the latter so the activated + // skill stays identifiable. + if (trimmed.startsWith('/')) return callerText; + return `${command} ${trimmed}`; +} + export function pluginCommandFromOrigin( origin: PromptOrigin | undefined, ): PluginCommandProjection | undefined { diff --git a/apps/pythinker-code/src/tui/utils/model-switch.ts b/apps/pythinker-code/src/tui/utils/model-switch.ts new file mode 100644 index 000000000..f17718f8f --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/model-switch.ts @@ -0,0 +1,209 @@ +import { + effectiveModelAlias, + type PythinkerConfig, + type ModelAlias, + type ThinkingEffort, +} from '@pymodel/pythinker-code-sdk'; + +import type { SlashCommandHost } from '../commands/dispatch'; +import { defaultThinkingEffortFor, modelDisplayName } from '../components/dialogs/model-selector'; +import type { AppState } from '../types'; +import { formatErrorMessage } from './event-payload'; +import { thinkingEffortFromConfig, thinkingEffortToConfig } from './thinking-config'; + +export const MODEL_SWITCH_CACHE_WARNING = + 'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + +/** True once the conversation has at least one user message: a switch from + * then on resends the accumulated context, losing the cache. Shell-command + * echoes are also 'user' transcript entries but carry an empty `bullet`, so + * they're excluded. */ +export function hasConversationHistory(host: SlashCommandHost): boolean { + return host.state.transcriptEntries.some( + (entry) => entry.kind === 'user' && entry.bullet !== '', + ); +} + +export function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias { + const providerType = host.state.appState.availableProviders[model.provider]?.type; + // Flat models (no named provider, e.g. inline base_url served by a v2 + // backend) have no provider entry to look up; their own protocol declaration + // plays the provider-identity role, mirroring the resolver. + return effectiveModelAlias(model, providerType ?? model.protocol); +} + +/** + * The model and thinking effort `config` gives the first lazily created + * session when it binds `alias`, for the session-less footer and the create + * call alike. + */ +export function lazyModelSelection( + config: PythinkerConfig, + alias: string | undefined, +): Partial { + // A default that disappeared from config (edited externally) clears the + // previously hydrated value instead of passing a stale explicit model to the + // first lazy-created session. + const patch: Partial = + alias === undefined ? { model: '', maxContextTokens: 0 } : { model: alias }; + const raw = alias === undefined ? undefined : config.models?.[alias]; + if (raw?.maxContextSize !== undefined) { + patch.maxContextTokens = raw.maxContextSize; + } + const effort = thinkingEffortFromConfig(config.thinking); + if (effort !== undefined) { + patch.thinkingEffort = effort; + } else if (raw !== undefined) { + // No concrete effort configured: mirror the engine, which resolves the + // model's default effort at createSession time. + const providerType = config.providers?.[raw.provider]?.type; + patch.thinkingEffort = defaultThinkingEffortFor( + effectiveModelAlias(raw, providerType ?? raw.protocol), + ); + } + return patch; +} + +/** + * How a model switch is saved: `session` keeps it to this session, `model` + * saves the model as the default (and the effort only when it changed), and + * `model-and-effort` saves both. + */ +export type ModelSwitchPersistence = 'session' | 'model' | 'model-and-effort'; + +export async function performModelSwitch( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, + persist: ModelSwitchPersistence, + quiet = false, +): Promise { + let session = host.session; + if (session === undefined) { + // A first prompt may still be inside lazy creation: wait it out so the + // switch lands on the new session instead of being overwritten by its + // assembly. + await host.waitForSessionAssembly(); + session = host.session; + } + if (host.state.appState.streamingPhase !== 'idle') { + host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); + return false; + } + + const prevModel = host.state.appState.model; + const prevEffort = host.state.appState.thinkingEffort; + const modelChanged = alias !== prevModel; + const effortChanged = effort !== prevEffort; + const runtimeChanged = modelChanged || effortChanged; + let effectiveAlias = alias; + let effectiveEffort = effort; + + try { + if (session === undefined && runtimeChanged) { + await host.authFlow.activateModelAfterLogin(alias, effort); + } else if (session !== undefined) { + if (alias !== prevModel) { + await session.setModel(alias); + } + if (effort !== prevEffort) { + await session.setThinking(effort); + } + const status = await session.getStatus(); + effectiveAlias = status.model ?? alias; + effectiveEffort = status.thinkingEffort; + } + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Failed to switch model: ${msg}`); + return false; + } + + if (session === undefined) { + effectiveAlias = host.state.appState.model; + effectiveEffort = host.state.appState.thinkingEffort; + } + const effectiveModelChanged = effectiveAlias !== prevModel; + const effectiveEffortChanged = effectiveEffort !== prevEffort; + const displayName = modelDisplayName( + effectiveAlias, + host.state.appState.availableModels[effectiveAlias], + ); + host.setAppState({ model: effectiveAlias, thinkingEffort: effectiveEffort }); + if (session === undefined && runtimeChanged) { + if (effectiveModelChanged) { + host.track('model_switch', { model: effectiveAlias }); + } + if (effectiveEffortChanged) { + host.track('thinking_toggle', { + enabled: effectiveEffort !== 'off', + effort: effectiveEffort, + from: prevEffort, + }); + } + } + + const saveDefault = persist !== 'session'; + let persisted = false; + if (saveDefault) { + try { + persisted = await persistModelSelection( + host, + effectiveAlias, + effectiveEffort, + effectiveEffortChanged || persist === 'model-and-effort', + ); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`); + return false; + } + } + + if (quiet) return true; + let status: string; + if (effectiveModelChanged) { + status = saveDefault + ? `Switched to ${displayName} with thinking ${effectiveEffort}.` + : `Switched to ${displayName} with thinking ${effectiveEffort} for this session only.`; + } else if (effectiveEffortChanged) { + status = saveDefault + ? `Thinking set to ${effectiveEffort}.` + : `Thinking set to ${effectiveEffort} for this session only.`; + } else if (saveDefault && persisted) { + status = `Saved ${displayName} with thinking ${effectiveEffort} as default.`; + } else { + status = `Already using ${displayName} with thinking ${effectiveEffort}.`; + } + host.showStatus(status, 'success'); + return true; +} + +async function persistModelSelection( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, + persistEffort: boolean, +): Promise { + const config = await host.harness.getConfig({ reload: true }); + const model = host.state.appState.availableModels[alias]; + const full = thinkingEffortToConfig( + effort, + model === undefined ? undefined : effectiveModelForHost(host, model), + ); + // Ordinary /model confirmation preserves the stored effort unless it changed; + // migration acceptance explicitly persists the target effort. + const patch = persistEffort ? full : { enabled: full.enabled }; + if ( + config.defaultModel === alias && + config.thinking?.enabled === patch.enabled && + (!persistEffort || config.thinking?.effort === patch.effort) + ) { + return false; + } + await host.harness.setConfig({ + defaultModel: alias, + thinking: patch, + }); + return true; +} diff --git a/apps/pythinker-code/src/tui/utils/refresh-providers.ts b/apps/pythinker-code/src/tui/utils/refresh-providers.ts deleted file mode 100644 index 25d376dfe..000000000 --- a/apps/pythinker-code/src/tui/utils/refresh-providers.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - refreshProviderModels, - type ProviderChange, - type RefreshProviderOptions, - type RefreshResult, -} from '@pymodel/pythinker-code-oauth'; -import type { PythinkerConfig, PythinkerConfigPatch } from '@pymodel/pythinker-code-sdk'; - -/** - * CLI-side host for provider-model refresh. Kept on the SDK's full config types - * so existing TUI callers and tests keep the SDK config shape. - */ -export interface RefreshProviderHost { - getConfig(): Promise; - removeProvider(providerId: string): Promise; - setConfig(patch: PythinkerConfigPatch): Promise; - /** Product User-Agent sent on custom-registry (api.json) fetches. */ - readonly userAgent?: string; -} - -export type { ProviderChange, RefreshProviderOptions, RefreshResult }; - -/** - * Refresh remote model metadata for the configured providers. Thin adapter over - * the shared `refreshProviderModels` orchestrator in `@pymodel/pythinker-code-oauth` - * (which is also what the daemon's scheduled/manual refresh uses). - */ -export async function refreshAllProviderModels( - host: RefreshProviderHost, - options: RefreshProviderOptions = {}, -): Promise { - return refreshProviderModels( - { - getConfig: () => host.getConfig(), - removeProvider: (providerId) => host.removeProvider(providerId), - setConfig: (patch) => host.setConfig(patch as unknown as PythinkerConfigPatch), - userAgent: host.userAgent, - }, - options, - ); -} diff --git a/apps/pythinker-code/src/tui/utils/survey-policy.ts b/apps/pythinker-code/src/tui/utils/survey-policy.ts index c57053cdc..b8359f1cd 100644 --- a/apps/pythinker-code/src/tui/utils/survey-policy.ts +++ b/apps/pythinker-code/src/tui/utils/survey-policy.ts @@ -356,7 +356,7 @@ export interface SurveyEventEnvironmentFields { readonly current_model: string; readonly pfc_model_id?: string; readonly pfc_trace_id?: string; - readonly user_turn_count: number; + readonly user_turn_count?: number; readonly cumulative_tokens: number; readonly virtual_context_tokens: number; readonly tool_call_count: number; diff --git a/apps/pythinker-code/src/utils/git/git-status.ts b/apps/pythinker-code/src/utils/git/git-status.ts index 833418305..380015fec 100644 --- a/apps/pythinker-code/src/utils/git/git-status.ts +++ b/apps/pythinker-code/src/utils/git/git-status.ts @@ -1,21 +1,29 @@ /** - * Cached git branch + working-tree status for the footer/statusline. + * Cached git branch and working-tree status for the footer. * - * Branch name refreshes every 5s, porcelain status every 15s. Branch - * and status reads stay synchronous with short timeouts. Pull request - * lookup uses an async cache so a slow `gh pr view` never blocks - * footer rendering. + * getStatus() only reads the cache. The first read, refresh(), a HEAD change, + * and a read past the 30s status TTL schedule a refresh. The git process + * starts on a later turn, not on the caller's stack, and not when the cache + * is constructed. HEAD is polled every 2 seconds. Pull request lookup stays + * async and never blocks a read. */ -import { execFile, spawnSync } from 'node:child_process'; +import { execFile } from 'node:child_process'; +import { join } from 'node:path'; + +import { FSWatcher } from 'chokidar'; import { resolveCommandPath } from '#/utils/process/resolve-command'; -const BRANCH_TTL_MS = 5_000; -const STATUS_TTL_MS = 15_000; +const STATUS_TTL_MS = 30_000; +const HEAD_POLL_INTERVAL_MS = 2_000; const PULL_REQUEST_TTL_MS = 60_000; -const SPAWN_TIMEOUT_MS = 500; +const SPAWN_TIMEOUT_MS = 2_000; const PR_SPAWN_TIMEOUT_MS = 5_000; +const GIT_MAX_BUFFER = 4 * 1024 * 1024; + +const AHEAD_BEHIND_RE = /\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]/; +const UNBORN_PREFIXES = ['No commits yet on ', 'Initial commit on ']; export interface GitStatus { readonly branch: string; @@ -33,26 +41,26 @@ export interface PullRequestInfo { } export interface GitStatusCache { - /** Returns current status, or `null` when workDir is not a git repo. */ + /** Returns the last status, or `null` when it is not available yet or this is not a git repo. */ getStatus(): GitStatus | null; + /** Refresh now, ignoring the status TTL. No-op when a refresh is already running; one follow-up runs after it. */ + refresh(): void; + dispose(): void; } export interface GitStatusCacheOptions { readonly onChange?: () => void; } -interface BranchState { - value: string | null; - fetchedAt: number; -} - interface StatusState { + branch: string | null; dirty: boolean; ahead: number; behind: number; diffAdded: number; diffDeleted: number; fetchedAt: number; + loaded: boolean; } interface PullRequestState { @@ -63,26 +71,37 @@ interface PullRequestState { requestId: number; } -const AHEAD_BEHIND_RE = /\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]/; +interface GitOutput { + readonly code: number; + readonly stdout: string; +} + +const EMPTY_STATUS: StatusState = { + branch: null, + dirty: false, + ahead: 0, + behind: 0, + diffAdded: 0, + diffDeleted: 0, + fetchedAt: 0, + loaded: false, +}; export function createGitStatusCache( workDir: string, options: GitStatusCacheOptions = {}, ): GitStatusCache { - // Resolve through PATH to an absolute path — a bare name would let cmd.exe - // pick up a `git.exe` planted in the workspace. const git = resolveCommandPath('git', workDir); + let disposed = false; + let epoch = 0; let repoDetected = false; let isRepo = false; - let branch: BranchState = { value: null, fetchedAt: 0 }; - let status: StatusState = { - dirty: false, - ahead: 0, - behind: 0, - diffAdded: 0, - diffDeleted: 0, - fetchedAt: 0, - }; + let inFlight = false; + let pendingForce = false; + let refreshQueued = false; + let queuedForce = false; + let headWatcher: FSWatcher | null = null; + let status: StatusState = EMPTY_STATUS; let pullRequest: PullRequestState = { value: null, branch: null, @@ -91,39 +110,193 @@ export function createGitStatusCache( requestId: 0, }; - return { + const cache: GitStatusCache = { getStatus: () => { - if (git === undefined) return null; + if (git === undefined || disposed) return null; if (repoDetected && !isRepo) return null; - if (!repoDetected) { - repoDetected = true; - isRepo = detectGitRepo(git, workDir); - } - if (!isRepo) return null; - const now = Date.now(); - if (now - branch.fetchedAt >= BRANCH_TTL_MS) { - branch = { value: readBranch(git, workDir), fetchedAt: now }; + if (!repoDetected || now - status.fetchedAt >= STATUS_TTL_MS) { + requestRefresh(false); } - if (branch.value === null) return null; - - if (now - status.fetchedAt >= STATUS_TTL_MS) { - status = { ...readStatus(git, workDir), fetchedAt: now }; + if (status.loaded && status.branch !== null) { + refreshPullRequestIfNeeded(status.branch, now); + } + return currentStatus(); + }, + refresh: () => { + requestRefresh(true); + }, + dispose: () => { + disposed = true; + epoch += 1; + inFlight = false; + pendingForce = false; + refreshQueued = false; + queuedForce = false; + const watcher = headWatcher; + headWatcher = null; + if (watcher !== null) { + void watcher.close().catch(() => undefined); } - refreshPullRequestIfNeeded(branch.value, now); - - return { - branch: branch.value, - dirty: status.dirty, - ahead: status.ahead, - behind: status.behind, - diffAdded: status.diffAdded, - diffDeleted: status.diffDeleted, - pullRequest: pullRequest.branch === branch.value ? pullRequest.value : null, - }; }, }; + return cache; + + function requestRefresh(force: boolean): void { + if (force) queuedForce = true; + if (refreshQueued) return; + refreshQueued = true; + queueMicrotask(() => { + refreshQueued = false; + const runForce = queuedForce; + queuedForce = false; + void runRefresh(runForce); + }); + } + + async function runRefresh(force: boolean): Promise { + if (disposed || git === undefined) return; + if (repoDetected && !isRepo) return; + if (inFlight) { + if (force) pendingForce = true; + return; + } + if (!force && repoDetected && Date.now() - status.fetchedAt < STATUS_TTL_MS) return; + + inFlight = true; + const epochAtStart = epoch; + try { + if (!repoDetected) { + await probeRepo(epochAtStart); + } + if (disposed || epoch !== epochAtStart || !isRepo) return; + await loadStatus(epochAtStart); + } finally { + if (epoch === epochAtStart) { + inFlight = false; + if (pendingForce && !disposed) { + pendingForce = false; + void runRefresh(true); + } + } + } + } + + async function probeRepo(epochAtStart: number): Promise { + if (git === undefined) return; + const result = await execGit(git, ['-C', workDir, 'rev-parse', '--absolute-git-dir']); + if (disposed || epoch !== epochAtStart) return; + repoDetected = true; + if (result === null || result.code !== 0) { + isRepo = false; + return; + } + const gitDir = result.stdout.trim(); + if (gitDir.length === 0) { + isRepo = false; + return; + } + isRepo = true; + startHeadWatch(join(gitDir, 'HEAD')); + } + + function startHeadWatch(headPath: string): void { + if (disposed || headWatcher !== null) return; + try { + const watcher = new FSWatcher({ + ignoreInitial: true, + persistent: false, + usePolling: true, + interval: HEAD_POLL_INTERVAL_MS, + ignorePermissionErrors: true, + }); + const onHead = (): void => { + requestRefresh(true); + }; + watcher.on('add', onHead); + watcher.on('change', onHead); + watcher.on('unlink', onHead); + watcher.on('error', () => undefined); + watcher.add(headPath); + headWatcher = watcher; + } catch { + // Turn end and the status TTL still refresh if watching is unavailable. + } + } + + async function loadStatus(epochAtStart: number): Promise { + if (git === undefined) return; + const result = await execGit(git, ['-C', workDir, 'status', '--porcelain', '-b']); + if (disposed || epoch !== epochAtStart) return; + if (result === null || result.code !== 0) { + status = { ...status, fetchedAt: Date.now() }; + return; + } + + const parsed = parsePorcelain(result.stdout); + if (parsed === null) { + status = { ...status, fetchedAt: Date.now() }; + return; + } + if (parsed.branch === null) { + publishStatus({ + branch: null, + dirty: false, + ahead: 0, + behind: 0, + diffAdded: 0, + diffDeleted: 0, + fetchedAt: Date.now(), + loaded: true, + }); + return; + } + + let diffAdded = 0; + let diffDeleted = 0; + if (parsed.dirty) { + const diff = await execGit(git, ['-C', workDir, 'diff', '--numstat', 'HEAD', '--']); + if (disposed || epoch !== epochAtStart) return; + if (diff !== null && diff.code === 0) { + const counts = parseDiffStats(diff.stdout); + diffAdded = counts.added; + diffDeleted = counts.deleted; + } + } + + publishStatus({ + branch: parsed.branch, + dirty: parsed.dirty, + ahead: parsed.ahead, + behind: parsed.behind, + diffAdded, + diffDeleted, + fetchedAt: Date.now(), + loaded: true, + }); + refreshPullRequestIfNeeded(parsed.branch, Date.now()); + } + + function publishStatus(next: StatusState): void { + const changed = !sameStatus(status, next); + status = next; + if (changed) options.onChange?.(); + } + + function currentStatus(): GitStatus | null { + if (!status.loaded || status.branch === null) return null; + return { + branch: status.branch, + dirty: status.dirty, + ahead: status.ahead, + behind: status.behind, + diffAdded: status.diffAdded, + diffDeleted: status.diffDeleted, + pullRequest: pullRequest.branch === status.branch ? pullRequest.value : null, + }; + } + function refreshPullRequestIfNeeded(branchName: string, now: number): void { if (pullRequest.pendingBranch === branchName) return; const fetchedAt = pullRequest.branch === branchName ? pullRequest.fetchedAt : 0; @@ -139,7 +312,7 @@ export function createGitStatusCache( }; void readPullRequest(workDir).then((value) => { - if (pullRequest.requestId !== requestId) return; + if (disposed || pullRequest.requestId !== requestId) return; const previous = pullRequest.branch === branchName ? pullRequest.value : null; const changed = !samePullRequest(previous, value); @@ -155,100 +328,89 @@ export function createGitStatusCache( } } -function detectGitRepo(git: string, workDir: string): boolean { - try { - const result = spawnSync(git, ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - }); - return result.status === 0 && result.stdout.trim() === 'true'; - } catch { - return false; - } -} - -function readBranch(git: string, workDir: string): string | null { - try { - const result = spawnSync(git, ['-C', workDir, 'branch', '--show-current'], { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - }); - if (result.status !== 0) return null; - const name = result.stdout.trim(); - return name.length > 0 ? name : null; - } catch { - return null; - } +function execGit(git: string, args: string[]): Promise { + return new Promise((resolve) => { + try { + execFile( + git, + args, + { + encoding: 'utf8', + timeout: SPAWN_TIMEOUT_MS, + maxBuffer: GIT_MAX_BUFFER, + }, + (error, stdout) => { + const output = typeof stdout === 'string' ? stdout : ''; + if (error === null) { + resolve({ code: 0, stdout: output }); + return; + } + const code = (error as { code?: unknown }).code; + if (typeof code === 'number') { + resolve({ code, stdout: output }); + return; + } + resolve(null); + }, + ); + } catch { + resolve(null); + } + }); } -function readStatus( - git: string, - workDir: string, -): { +function parsePorcelain(stdout: string): { + branch: string | null; dirty: boolean; ahead: number; behind: number; - diffAdded: number; - diffDeleted: number; -} { - try { - const result = spawnSync(git, ['-C', workDir, 'status', '--porcelain', '-b'], { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - maxBuffer: 4 * 1024 * 1024, - }); - if (result.status !== 0) { - return { dirty: false, ahead: 0, behind: 0, diffAdded: 0, diffDeleted: 0 }; +} | null { + let header: string | null = null; + let dirty = false; + for (const line of stdout.split('\n')) { + if (line.startsWith('## ')) { + header = line; + } else if (line.trim().length > 0) { + dirty = true; } + } + if (header === null) return null; + const parsed = parsePorcelainHeader(header); + if (parsed === null) return null; + return { ...parsed, dirty: parsed.branch !== null && dirty }; +} - let dirty = false; - let ahead = 0; - let behind = 0; - for (const line of result.stdout.split('\n')) { - if (line.startsWith('## ')) { - const m = AHEAD_BEHIND_RE.exec(line); - if (m) { - ahead = Number.parseInt(m[1] ?? '0', 10) || 0; - behind = Number.parseInt(m[2] ?? '0', 10) || 0; - } - } else if (line.trim().length > 0) { - dirty = true; - } +function parsePorcelainHeader(line: string): { branch: string | null; ahead: number; behind: number } | null { + if (!line.startsWith('## ')) return null; + let body = line.slice(3); + const aheadBehind = AHEAD_BEHIND_RE.exec(body); + const ahead = Number.parseInt(aheadBehind?.[1] ?? '0', 10) || 0; + const behind = Number.parseInt(aheadBehind?.[2] ?? '0', 10) || 0; + const bracket = body.indexOf(' ['); + if (bracket >= 0) body = body.slice(0, bracket); + if (body === 'HEAD (no branch)') return { branch: null, ahead: 0, behind: 0 }; + for (const prefix of UNBORN_PREFIXES) { + if (body.startsWith(prefix)) { + body = body.slice(prefix.length); + break; } - const diff = dirty ? readDiffStats(git, workDir) : { added: 0, deleted: 0 }; - return { - dirty, - ahead, - behind, - diffAdded: diff.added, - diffDeleted: diff.deleted, - }; - } catch { - return { dirty: false, ahead: 0, behind: 0, diffAdded: 0, diffDeleted: 0 }; } + const upstream = body.indexOf('...'); + if (upstream >= 0) body = body.slice(0, upstream); + const branch = body.trim(); + return { branch: branch.length > 0 ? branch : null, ahead, behind }; } -function readDiffStats(git: string, workDir: string): { added: number; deleted: number } { - try { - const result = spawnSync(git, ['-C', workDir, 'diff', '--numstat', 'HEAD', '--'], { - encoding: 'utf8', - timeout: SPAWN_TIMEOUT_MS, - maxBuffer: 4 * 1024 * 1024, - }); - if (result.status !== 0) return { added: 0, deleted: 0 }; - - let added = 0; - let deleted = 0; - for (const line of result.stdout.split('\n')) { - if (!line) continue; - const [addedText, deletedText] = line.split('\t'); - added += parseDiffNumstatCount(addedText); - deleted += parseDiffNumstatCount(deletedText); - } - return { added, deleted }; - } catch { - return { added: 0, deleted: 0 }; +function parseDiffStats(stdout: string): { added: number; deleted: number } { + let added = 0; + let deleted = 0; + for (const line of stdout.split('\n')) { + if (!line) continue; + const [addedText, deletedText] = line.split('\t'); + added += parseDiffNumstatCount(addedText); + deleted += parseDiffNumstatCount(deletedText); } + return { added, deleted }; } function parseDiffNumstatCount(value: string | undefined): number { @@ -257,6 +419,18 @@ function parseDiffNumstatCount(value: string | undefined): number { return Number.isFinite(n) && n > 0 ? n : 0; } +function sameStatus(a: StatusState, b: StatusState): boolean { + return ( + a.loaded === b.loaded && + a.branch === b.branch && + a.dirty === b.dirty && + a.ahead === b.ahead && + a.behind === b.behind && + a.diffAdded === b.diffAdded && + a.diffDeleted === b.diffDeleted + ); +} + function readPullRequest(workDir: string): Promise { return new Promise((resolve) => { // Resolve gh through PATH as well — this runs with cwd = workDir, where a diff --git a/apps/pythinker-code/src/utils/model-migration-state.ts b/apps/pythinker-code/src/utils/model-migration-state.ts new file mode 100644 index 000000000..fe1167220 --- /dev/null +++ b/apps/pythinker-code/src/utils/model-migration-state.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +import { getModelMigrationStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +/** + * Records, per source model alias, the target model id a migration prompt was + * already answered for. The prompt returns only when the registry declares a + * different target. + */ +const ModelMigrationStateSchema = z.object({ + version: z.literal(1), + acknowledged: z.record(z.string().min(1), z.string().min(1)), +}); + +export async function readModelMigrationAcknowledgements( + filePath: string = getModelMigrationStateFile(), +): Promise> { + try { + const state = await readJsonFile(filePath, ModelMigrationStateSchema, { + version: 1, + acknowledged: {}, + }); + return state.acknowledged; + } catch { + return {}; + } +} + +export async function acknowledgeModelMigration( + fromAlias: string, + toModel: string, + filePath: string = getModelMigrationStateFile(), +): Promise { + const acknowledged = await readModelMigrationAcknowledgements(filePath); + await writeJsonFile(filePath, ModelMigrationStateSchema, { + version: 1, + acknowledged: { ...acknowledged, [fromAlias]: toModel }, + }); +} diff --git a/apps/pythinker-code/src/utils/paths.ts b/apps/pythinker-code/src/utils/paths.ts index cd88698bf..149c087b4 100644 --- a/apps/pythinker-code/src/utils/paths.ts +++ b/apps/pythinker-code/src/utils/paths.ts @@ -18,6 +18,7 @@ import { PYTHINKER_CODE_HOME_ENV, PYTHINKER_CODE_INPUT_HISTORY_DIR_NAME, PYTHINKER_CODE_LOG_DIR_NAME, + PYTHINKER_CODE_MODEL_MIGRATION_STATE_FILE_NAME, PYTHINKER_CODE_NATIVE_STAGED_STATE_FILE_NAME, PYTHINKER_CODE_NATIVE_STAGING_DIR_NAME, PYTHINKER_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME, @@ -136,6 +137,10 @@ export function getRecommendedEffortStateFile(): string { return join(getDataDir(), PYTHINKER_CODE_RECOMMENDED_EFFORT_STATE_FILE_NAME); } +export function getModelMigrationStateFile(): string { + return join(getDataDir(), PYTHINKER_CODE_MODEL_MIGRATION_STATE_FILE_NAME); +} + /** * Return the user input history file for a given working directory. * Layout: `/user-history/.jsonl`. diff --git a/apps/pythinker-code/src/utils/process/resolve-command.ts b/apps/pythinker-code/src/utils/process/resolve-command.ts index 721342e60..3f0054410 100644 --- a/apps/pythinker-code/src/utils/process/resolve-command.ts +++ b/apps/pythinker-code/src/utils/process/resolve-command.ts @@ -1,5 +1,5 @@ import { accessSync, constants, statSync } from 'node:fs'; -import { isAbsolute, join, relative, resolve } from 'node:path'; +import { isAbsolute, join, parse, relative, resolve } from 'node:path'; // cmd.exe / CreateProcess search the current directory before PATH, so on // Windows a bare command name can execute a binary planted in the workspace @@ -43,6 +43,7 @@ function isExecutableFile(candidate: string, platform: NodeJS.Platform): boolean function isInsideCwd(candidate: string, cwd: string, platform: NodeJS.Platform): boolean { let resolvedCandidate = resolve(candidate); let resolvedCwd = resolve(cwd); + if (resolvedCwd === parse(resolvedCwd).root) return false; if (platform === 'win32') { resolvedCandidate = resolvedCandidate.toLowerCase(); resolvedCwd = resolvedCwd.toLowerCase(); @@ -71,7 +72,7 @@ export function resolveCommandPath(command: string, cwd: string = process.cwd()) for (const name of names) { const candidate = join(dir, name); if (!isExecutableFile(candidate, platform)) continue; - if (isInsideCwd(candidate, cwd, platform)) return undefined; + if (isInsideCwd(candidate, cwd, platform)) continue; return resolve(candidate); } } diff --git a/apps/pythinker-code/test/cli/web/remote-control.test.ts b/apps/pythinker-code/test/cli/web/remote-control.test.ts index cf9666739..98402beab 100644 --- a/apps/pythinker-code/test/cli/web/remote-control.test.ts +++ b/apps/pythinker-code/test/cli/web/remote-control.test.ts @@ -59,6 +59,9 @@ describe('Remote Control output', () => { it('formats relay and device lifecycle states', () => { expect(formatRemoteControlStatus('relay_connected').toLowerCase()).toContain('connected'); expect(formatRemoteControlStatus('relay_disconnected')).toContain('disconnected'); + expect(formatRemoteControlStatus('relay_terminated')).toContain('offline'); + expect(formatRemoteControlStatus('relay_terminated')).toContain('exiting'); + expect(formatRemoteControlStatus('relay_terminated')).not.toContain('reconnecting'); expect(formatRemoteControlStatus('device_connected').toLowerCase()).toContain('connected'); expect(formatRemoteControlStatus('device_disconnected')).toContain('disconnected'); }); diff --git a/apps/pythinker-code/test/cli/web/web.test.ts b/apps/pythinker-code/test/cli/web/web.test.ts index d48e83830..2a798aea7 100644 --- a/apps/pythinker-code/test/cli/web/web.test.ts +++ b/apps/pythinker-code/test/cli/web/web.test.ts @@ -19,6 +19,10 @@ import { resetCapabilitiesCache, setCapabilities } from '@pymodel/pi-tui'; import { registerWebCommand } from '#/cli/sub/web'; import type { LegacyKillDeps } from '#/cli/sub/web/legacy-kill'; +import type { + RemoteControlCloseReason, + RemoteControlStatus, +} from '#/cli/sub/web/remote-control'; import type { WebCommandDeps } from '#/cli/sub/web/run'; import type { ParsedServerOptions } from '#/cli/sub/web/shared'; import { darkColors } from '#/tui/theme/colors'; @@ -464,6 +468,100 @@ describe('`pythinker web` opens the browser', () => { ).rejects.toThrow('--remote-control requires a loopback host.'); }); + it('requests foreground shutdown when the relay takes the device offline', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const home = mkdtempSync(join(tmpdir(), 'pythinker-web-rc-')); + vi.stubEnv('PYTHINKER_CODE_HOME', home); + vi.stubEnv('PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY', 'relay-key'); + const { stdout, stderr, readStdout } = makeIo(); + const requestShutdown = vi.fn(); + const runner: ForegroundRunner = async (_options, hooks) => { + await hooks?.onReady?.('http://127.0.0.1:58627', { requestShutdown }); + return undefined as never; + }; + let emitStatus: ((status: RemoteControlStatus) => void) | undefined; + let resolveClosed: ((reason: RemoteControlCloseReason) => void) | undefined; + const startRemoteControl: WebCommandDeps['startRemoteControl'] = async (options) => { + emitStatus = options.onStatus; + return { + deviceId: 'device-1', + deviceName: 'example-device', + url: 'https://example.test/devices/device-1/?rc=1&from=pythinker_code_cli', + closed: new Promise((resolve) => { + resolveClosed = resolve; + }), + close: async () => {}, + }; + }; + + try { + await handleWebCommand( + { remoteControl: true, open: false }, + { + startServerForeground: runner, + startRemoteControl, + resolveToken: () => 'tok', + openUrl: vi.fn(), + stdout, + stderr, + }, + ); + + emitStatus?.('relay_terminated'); + resolveClosed?.('user_requested'); + await vi.waitFor(() => { + expect(requestShutdown).toHaveBeenCalledWith('remote_control_user_requested'); + }); + expect(readStdout()).toContain('offline'); + expect(readStdout()).not.toContain('reconnecting'); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it('does not request shutdown when the tunnel closes locally', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const home = mkdtempSync(join(tmpdir(), 'pythinker-web-rc-')); + vi.stubEnv('PYTHINKER_CODE_HOME', home); + vi.stubEnv('PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY', 'relay-key'); + const { stdout, stderr } = makeIo(); + const requestShutdown = vi.fn(); + const runner: ForegroundRunner = async (_options, hooks) => { + await hooks?.onReady?.('http://127.0.0.1:58627', { requestShutdown }); + return undefined as never; + }; + let resolveClosed: ((reason: RemoteControlCloseReason) => void) | undefined; + const startRemoteControl: WebCommandDeps['startRemoteControl'] = async () => ({ + deviceId: 'device-1', + deviceName: 'example-device', + url: 'https://example.test/devices/device-1/?rc=1&from=pythinker_code_cli', + closed: new Promise((resolve) => { + resolveClosed = resolve; + }), + close: async () => {}, + }); + + try { + await handleWebCommand( + { remoteControl: true, open: false }, + { + startServerForeground: runner, + startRemoteControl, + resolveToken: () => 'tok', + openUrl: vi.fn(), + stdout, + stderr, + }, + ); + + resolveClosed?.('local_close'); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(requestShutdown).not.toHaveBeenCalled(); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + it('shows --remote-control in help', () => { const remoteControlOption = makeProgram() .commands.find((command) => command.name() === 'web')! diff --git a/apps/pythinker-code/test/tui/commands/provider.test.ts b/apps/pythinker-code/test/tui/commands/provider.test.ts index d3bca1080..10c7a36a4 100644 --- a/apps/pythinker-code/test/tui/commands/provider.test.ts +++ b/apps/pythinker-code/test/tui/commands/provider.test.ts @@ -35,7 +35,7 @@ function makeHost( }; const host = { state: { appState }, - waitForLazyCreation: vi.fn(async () => {}), + waitForSessionAssembly: vi.fn(async () => {}), harness: { setConfig: vi.fn(async () => ({})), }, @@ -51,7 +51,7 @@ function makeHost( refreshConfigAfterLogin: ReturnType; activateModelAfterLogin: ReturnType; }; - waitForLazyCreation: ReturnType; + waitForSessionAssembly: ReturnType; track: ReturnType; }; return { host }; @@ -147,9 +147,9 @@ describe('setDefaultModel', () => { await setDefaultModel(host, 'opus', 'high'); - expect(host.waitForLazyCreation).toHaveBeenCalled(); + expect(host.waitForSessionAssembly).toHaveBeenCalled(); expect( - host.waitForLazyCreation.mock.invocationCallOrder[0]!, + host.waitForSessionAssembly.mock.invocationCallOrder[0]!, ).toBeLessThan(host.harness.setConfig.mock.invocationCallOrder[0]!); }); }); diff --git a/apps/pythinker-code/test/tui/commands/resolve.test.ts b/apps/pythinker-code/test/tui/commands/resolve.test.ts index 9748f7e65..f897786c1 100644 --- a/apps/pythinker-code/test/tui/commands/resolve.test.ts +++ b/apps/pythinker-code/test/tui/commands/resolve.test.ts @@ -1,5 +1,4 @@ import { - resolveSkillCommand, resolveSlashCommandInput, setExperimentalFeatures, slashBusyMessage, @@ -7,6 +6,8 @@ import { } from '#/tui/commands/index'; import { afterEach, describe, expect, it } from 'vitest'; +import { resolveUserInput } from '#/tui/commands/resolve'; + function resolve( input: string, overrides: Partial[0]> = {}, @@ -21,6 +22,47 @@ function resolve( }); } +describe('resolveUserInput', () => { + const options = { + isStreaming: false, + isCompacting: false, + skillCommandMap: new Map([ + ['skill:review', 'review'], + ['quick', 'review'], + ['skill:security', 'security'], + ['skill:team/review', 'team/review'], + ]), + pluginCommandMap: new Map([['example:deploy', 'example']]), + }; + + it.each([ + { input: '/skill:review src/app.ts', skills: [{ skillName: 'review', args: 'src/app.ts' }] }, + { input: '/review', skills: [{ skillName: 'review', args: '' }] }, + { input: '/skill:team/review src/app.ts', skills: [{ skillName: 'team/review', args: 'src/app.ts' }] }, + { input: '/team/review src/app.ts', skills: [] }, + { input: 'check /skill:team/review src/app.ts', skills: [] }, + { input: '/quick\ncheck src/app.ts', skills: [] }, + { input: '/skill:review\nexplain this', skills: [] }, + { input: '/skill:review\n\nexplain this', skills: [] }, + { input: '/skill:review\texplain this', skills: [] }, + { input: '/quick \ncheck src/app.ts', skills: [{ skillName: 'review', args: 'check src/app.ts' }] }, + { input: '/quick\n', skills: [{ skillName: 'review', args: '' }] }, + { input: 'please /skill:review src/app.ts', skills: [{ skillName: 'review' }] }, + { input: '/skill:review /review src/app.ts', skills: [{ skillName: 'review' }] }, + { input: '/quick /skill:review src/app.ts', skills: [{ skillName: 'review' }] }, + { + input: '/skill:security\n/quick /skill:security', + skills: [{ skillName: 'security' }, { skillName: 'review' }], + }, + { input: '/unknown /skill:review src/app.ts', skills: [{ skillName: 'review' }] }, + { input: '/help /skill:review', skills: [] }, + { input: '/example:deploy /skill:review', skills: [] }, + { input: 'check src/app.ts https://example.com/review and 1/2', skills: [] }, + ])('resolves $input without changing argument or command precedence', ({ input, skills }) => { + expect(resolveUserInput({ ...options, input }).activations).toEqual(skills); + }); +}); + describe('resolveSlashCommandInput', () => { afterEach(() => { setExperimentalFeatures([]); @@ -314,15 +356,68 @@ describe('goal command resolution', () => { }); describe('slash command busy helpers', () => { - it('resolves skill command aliases with and without skill prefix', () => { - const map = new Map([ + it('resolves skill commands by explicit prefixed name and by shorthand', () => { + const skillCommandMap = new Map([ ['skill:review', 'review'], ['mcp-config', 'mcp-config'], ]); - expect(resolveSkillCommand(map, 'skill:review')).toBe('review'); - expect(resolveSkillCommand(map, 'review')).toBe('review'); - expect(resolveSkillCommand(map, 'mcp-config')).toBe('mcp-config'); + expect(resolve('/skill:review', { skillCommandMap })).toMatchObject({ + kind: 'skill', + skillName: 'review', + }); + expect(resolve('/review', { skillCommandMap })).toMatchObject({ + kind: 'skill', + skillName: 'review', + }); + expect(resolve('/mcp-config', { skillCommandMap })).toMatchObject({ + kind: 'skill', + skillName: 'mcp-config', + }); + }); + + it('resolves a qualified plugin skill by shorthand when no plugin command takes the name', () => { + const skillCommandMap = new Map([['skill:my-plugin:deploy', 'my-plugin:deploy']]); + expect(resolve('/my-plugin:deploy', { skillCommandMap })).toEqual({ + kind: 'skill', + commandName: 'my-plugin:deploy', + skillName: 'my-plugin:deploy', + args: '', + }); + }); + + it('resolves a namespaced input to the plugin command when a same-named plugin skill exists', () => { + const skillCommandMap = new Map([['skill:my-plugin:deploy', 'my-plugin:deploy']]); + const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy $ARGUMENTS']]); + expect(resolve('/my-plugin:deploy prod', { skillCommandMap, pluginCommandMap })).toEqual({ + kind: 'plugin-command', + commandName: 'deploy', + pluginId: 'my-plugin', + args: 'prod', + }); + }); + + it('keeps the colliding plugin skill reachable through its explicit skill command', () => { + const skillCommandMap = new Map([['skill:my-plugin:deploy', 'my-plugin:deploy']]); + const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy']]); + expect(resolve('/skill:my-plugin:deploy', { skillCommandMap, pluginCommandMap })).toEqual({ + kind: 'skill', + commandName: 'skill:my-plugin:deploy', + skillName: 'my-plugin:deploy', + args: '', + }); + }); + + it('blocks a plugin command while streaming even when a same-named plugin skill exists', () => { + const skillCommandMap = new Map([['skill:my-plugin:deploy', 'my-plugin:deploy']]); + const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy']]); + expect( + resolve('/my-plugin:deploy', { skillCommandMap, pluginCommandMap, isStreaming: true }), + ).toEqual({ + kind: 'blocked', + commandName: 'my-plugin:deploy', + reason: 'streaming', + }); }); it('formats busy messages', () => { diff --git a/apps/pythinker-code/test/tui/commands/tower.test.ts b/apps/pythinker-code/test/tui/commands/tower.test.ts index e08da13a1..243654a45 100644 --- a/apps/pythinker-code/test/tui/commands/tower.test.ts +++ b/apps/pythinker-code/test/tui/commands/tower.test.ts @@ -4,7 +4,7 @@ import type { Session } from '@pymodel/pythinker-code-sdk'; import { handleTowerCommand } from '#/tui/commands/index'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; -import { TOWER_STATUS_PROMPT, TOWER_TEARDOWN_PROMPT } from '#/tui/constant/pythinker-tui'; +import { TOWER_TEARDOWN_PROMPT } from '#/tui/constant/pythinker-tui'; function makeHost( overrides: { @@ -20,6 +20,9 @@ function makeHost( if (!(overrides.refuseTowerEntry && enabled)) engineMode = enabled; }), getStatus: vi.fn(async () => ({ towerMode: engineMode })), + getTowerStatus: vi.fn(async (): Promise => + engineMode ? 'Tower mode: ON\nTower is not initialized.' : 'Tower mode: OFF', + ), }; const hasSession = overrides.hasSession ?? true; const host = { @@ -43,28 +46,95 @@ function makeHost( showStatus: vi.fn(), showNotice: vi.fn(), sendNormalUserInput: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), } as unknown as SlashCommandHost; return { host, session }; } describe('handleTowerCommand', () => { - it('reports tower status when called without args, without touching the mode', async () => { + it('reads tower status without args through the SDK without prompting or touching the editor', async () => { const { host, session } = makeHost({ towerMode: false }); await handleTowerCommand(host, ''); - expect(host.sendNormalUserInput).toHaveBeenCalledWith(TOWER_STATUS_PROMPT); + expect(session.getTowerStatus).toHaveBeenCalledOnce(); + expect(host.showNotice).toHaveBeenCalledWith('Tower mode: OFF', undefined); expect(session.setTowerMode).not.toHaveBeenCalled(); expect(host.ensureSession).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.restoreEditor).not.toHaveBeenCalled(); + expect(host.restoreInputText).not.toHaveBeenCalled(); }); - it('reports tower status for the status subcommand, without touching the mode', async () => { + it('reports active but uninitialized status without calling it OFF', async () => { const { host, session } = makeHost({ towerMode: true }); await handleTowerCommand(host, 'status'); - expect(host.sendNormalUserInput).toHaveBeenCalledWith(TOWER_STATUS_PROMPT); + expect(session.getTowerStatus).toHaveBeenCalledOnce(); + expect(host.showNotice).toHaveBeenCalledWith('Tower mode: ON', 'Tower is not initialized.'); + expect(host.showNotice).not.toHaveBeenCalledWith('Tower mode: OFF', undefined); expect(session.setTowerMode).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('shows the engine-rendered status summary through showNotice', async () => { + const { host, session } = makeHost({ towerMode: true }); + const detail = [ + 'Base: main (mode: branch) · You are: tower', + 'Missions:', + ' M1 Build engine — active · owner w1 · feat/build-engine', + 'Roster:', + ' w1 (worker) — mission M1 · agent-w1', + 'Review gate:', + ' M1 feat/build-engine — BLOCKED: not-completed', + 'Inbox: 2 message(s)', + 'Concurrency: budget: 3 agent(s) · inflight: 1 · spawns open', + 'Recent activity:', + ' 2026-09-30T00:00:00.000Z tower plan missions=1', + ].join('\n'); + session.getTowerStatus.mockResolvedValueOnce(`Tower status — ON\n${detail}`); + + await handleTowerCommand(host, 'status'); + + expect(host.showNotice).toHaveBeenCalledWith('Tower status — ON', detail); + + session.getTowerStatus.mockResolvedValueOnce(`Tower mode: OFF\n${detail}`); + await handleTowerCommand(host, 'status'); + + expect(host.showNotice).toHaveBeenCalledWith('Tower mode: OFF', detail); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('lazy-creates a missing session before reading tower status', async () => { + const { host, session } = makeHost({ hasSession: false }); + + await handleTowerCommand(host, 'status'); + + expect(host.ensureSession).toHaveBeenCalledOnce(); + expect(session.getTowerStatus).toHaveBeenCalledOnce(); + expect(host.showNotice).toHaveBeenCalledWith('Tower mode: OFF', undefined); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('shows status errors without a prompt fallback', async () => { + const { host, session } = makeHost({ towerMode: true }); + session.getTowerStatus.mockRejectedValueOnce(new Error('backend unavailable')); + + await handleTowerCommand(host, 'status'); + + expect(host.showError).toHaveBeenCalledWith( + 'Failed to read tower status: backend unavailable', + ); + expect(host.showNotice).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.restoreEditor).not.toHaveBeenCalled(); + expect(host.restoreInputText).not.toHaveBeenCalled(); }); it('sends the teardown instruction for the teardown subcommand, without touching the mode', async () => { diff --git a/apps/pythinker-code/test/tui/commands/web.test.ts b/apps/pythinker-code/test/tui/commands/web.test.ts index f9a8fdac1..8aa81e2d0 100644 --- a/apps/pythinker-code/test/tui/commands/web.test.ts +++ b/apps/pythinker-code/test/tui/commands/web.test.ts @@ -60,7 +60,7 @@ function makeHost() { setExitOpenUrl: vi.fn(), setExitForegroundTask: vi.fn(), stop: vi.fn(async () => {}), - waitForLazyCreation: vi.fn(async () => {}), + waitForSessionAssembly: vi.fn(async () => {}), } as unknown as SlashCommandHost & { showStatus: ReturnType; showError: ReturnType; @@ -69,7 +69,7 @@ function makeHost() { setExitOpenUrl: ReturnType; setExitForegroundTask: ReturnType; stop: ReturnType; - waitForLazyCreation: ReturnType; + waitForSessionAssembly: ReturnType; }; return host; } @@ -220,6 +220,7 @@ describe('handleRemoteControlCommand', () => { deviceId: 'device-1', deviceName: 'example-device', url: entryUrl, + closed: new Promise(() => {}), close, }); mocks.startServerForeground.mockImplementation( @@ -289,6 +290,7 @@ describe('handleRemoteControlCommand', () => { deviceId: 'device-1', deviceName: 'example-device', url: entryUrl, + closed: new Promise(() => {}), close, }); mocks.startServerForeground.mockImplementation( @@ -310,7 +312,7 @@ describe('handleRemoteControlCommand', () => { try { await handleRemoteControlCommand(host); - expect(host.waitForLazyCreation).toHaveBeenCalledOnce(); + expect(host.waitForSessionAssembly).toHaveBeenCalledOnce(); expect(host.showError).not.toHaveBeenCalled(); expect(host.setExitForegroundTask).toHaveBeenCalledOnce(); expect(host.stop).toHaveBeenCalledOnce(); diff --git a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts index 00c03d7eb..499baae18 100644 --- a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts @@ -20,6 +20,8 @@ beforeEach(() => { gitStatusMocks.createGitStatusCache.mockClear(); gitStatusMocks.createGitStatusCache.mockImplementation(() => ({ getStatus: vi.fn(() => null), + refresh: vi.fn(), + dispose: vi.fn(), })); }); @@ -79,6 +81,21 @@ const appState: AppState = { }; describe('FooterComponent', () => { + it('asks the git cache to refresh and disposes it with the footer', () => { + const refresh = vi.fn(); + const dispose = vi.fn(); + gitStatusMocks.createGitStatusCache.mockImplementation(() => ({ + getStatus: () => null, + refresh, + dispose, + })); + const footer = new FooterComponent(appState); + footer.refreshGitStatus(); + expect(refresh).toHaveBeenCalledTimes(1); + footer.dispose(); + expect(dispose).toHaveBeenCalledTimes(1); + }); + const previousChalkLevel = chalk.level; beforeEach(() => { diff --git a/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts b/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts index ce43a719c..557aa6b99 100644 --- a/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts @@ -118,6 +118,8 @@ describe('WelcomeComponent', () => { diffDeleted: 1, pullRequest: null, }), + refresh: () => {}, + dispose: () => {}, }; const plain = renderWelcomeBanner({ width: 120, diff --git a/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts index bdb13ffcb..21ff34d19 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts @@ -1,3 +1,6 @@ +import { stripVTControlCharacters } from 'node:util'; + +import { visibleWidth } from '@pymodel/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { ChoicePickerComponent, type ChoiceOption } from '#/tui/components/dialogs/choice-picker'; @@ -7,7 +10,6 @@ import { SettingsSelectorComponent } from '#/tui/components/dialogs/settings-sel import { ThemeSelectorComponent } from '#/tui/components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '#/tui/components/dialogs/update-preference-selector'; import { currentTheme } from '#/tui/theme'; -import { darkColors } from '#/tui/theme/colors'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -16,6 +18,43 @@ function strip(text: string): string { } describe('ChoicePickerComponent', () => { + it('keeps ordinary notices as plain text', () => { + const picker = new ChoicePickerComponent({ + title: 'Pick one', + notice: '**Plain notice**', + options: [{ value: 'keep', label: 'Keep current' }], + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + expect(picker.render(100).map(strip)).toContain(' **Plain notice**'); + }); + + it.each([24, 100])('renders Markdown notices and preserves paragraphs at width %s', (width) => { + const picker = new ChoicePickerComponent({ + title: 'Migration', + notice: { markdown: '### Upgrade\n\nUse **next** model.\n\n- Faster replies\n- [Details](https://example.com/upgrade)' }, + options: [{ value: 'keep', label: 'Keep current' }], + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const rendered = picker.render(width); + const lines = rendered.map(stripVTControlCharacters); + const output = lines.join('\n'); + expect(output).toContain(' Upgrade'); + expect(output).toContain(' Use next model.'); + expect(output).toContain('Faster replies'); + expect(output).toContain('Details'); + expect(output).not.toContain('###'); + expect(output).not.toContain('**'); + expect(output).not.toContain('[Details]'); + const paragraph = lines.findIndex((line) => line.includes('Use next model.')); + expect(lines[paragraph - 1]?.trim()).toBe(''); + expect(lines[paragraph + 1]?.trim()).toBe(''); + expect(rendered.every((line) => visibleWidth(line) <= width)).toBe(true); + }); + it('uses the model-dialog header vocabulary (capitalized keys, "type to search")', () => { const picker = new ChoicePickerComponent({ title: 'Add provider', @@ -150,6 +189,21 @@ describe('ChoicePickerComponent', () => { expect(onSelect).toHaveBeenCalledWith('a'); }); + it.each([' ', '\u001B[32u'])('requires Enter when spaceSelect is disabled (%j)', (space) => { + const onSelect = vi.fn(); + const picker = new ChoicePickerComponent({ + title: 'Confirm migration', + options: [{ value: 'switch', label: 'Switch model' }], + spaceSelect: false, + onSelect, + onCancel: vi.fn(), + }); + picker.handleInput(space); + expect(onSelect).not.toHaveBeenCalled(); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('switch'); + }); + it('renders the selected option description in descriptionTone, others in textMuted', () => { const options: ChoiceOption[] = [ { value: 'none', label: 'No attachment', description: 'Text feedback only' }, diff --git a/apps/pythinker-code/test/tui/components/messages/sticky-user-message.test.ts b/apps/pythinker-code/test/tui/components/messages/sticky-user-message.test.ts index e9e93ac0d..95b37720e 100644 --- a/apps/pythinker-code/test/tui/components/messages/sticky-user-message.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/sticky-user-message.test.ts @@ -1,7 +1,10 @@ import { visibleWidth, type TuiMouseEvent, type TuiMouseEventType } from '@pymodel/pi-tui'; +import chalk from 'chalk'; import { describe, expect, it, vi } from 'vitest'; import { StickyUserMessageComponent } from '#/tui/components/messages/sticky-user-message'; +import { UserMessageComponent } from '#/tui/components/messages/user-message'; +import { currentTheme } from '#/tui/theme'; function stripAnsi(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -41,6 +44,23 @@ describe('StickyUserMessageComponent', () => { expect(component.setJudgment(null)).toBe(false); }); + it('keeps the user message skill highlighting in a Fullscreen summary', () => { + const summary = '/skill:review check src/app.ts'; + const user = new UserMessageComponent(summary, undefined, undefined, ['review']); + const component = new StickyUserMessageComponent(() => {}); + component.setJudgment({ component: user, summary, targetY: 2 }); + + const previousLevel = chalk.level; + chalk.level = 3; + try { + const line = component.render(80)[0]!; + expect(stripAnsi(line)).toBe(`❯ ${summary}`); + expect(line).toContain(currentTheme.boldFg('primary', '/skill:review')); + } finally { + chalk.level = previousLevel; + } + }); + it('truncates long summaries to one row and supports narrow widths', () => { const component = new StickyUserMessageComponent(() => {}); component.setJudgment(judgment('word '.repeat(200).trim())); diff --git a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts index a9a17f1d3..d0d28b8b4 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts @@ -349,6 +349,28 @@ describe('ToolCallComponent', () => { expect(component.hasHiddenContent()).toBe(true); }); + it('uses the title as the header and keeps the message behind ctrl+o', () => { + const component = new ToolCallComponent( + { + id: 'call_notify_titled', + name: 'NotifyUser', + args: { title: 'Root cause found', message }, + }, + { tool_call_id: 'call_notify_titled', output: 'Update shown to the user.', is_error: false }, + ); + + const collapsed = component.render(100).map(strip).filter((line) => line.trim().length > 0); + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toContain('Sent you an update'); + expect(collapsed[0]).toContain('Root cause found'); + expect(collapsed[0]).not.toContain('Login module is clean.'); + + component.setExpanded(true); + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('Login module is clean.'); + expect(expanded).toContain('session expiry'); + }); + it('renders historical calls as ordinary tool records when disabled', () => { setExperimentalFeatures([]); const component = new ToolCallComponent( diff --git a/apps/pythinker-code/test/tui/components/messages/user-message.test.ts b/apps/pythinker-code/test/tui/components/messages/user-message.test.ts index 31d6b6f96..b8ef5d94c 100644 --- a/apps/pythinker-code/test/tui/components/messages/user-message.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/user-message.test.ts @@ -6,7 +6,7 @@ import { UserMessageComponent, userMessageLineHeights, } from '#/tui/components/messages/user-message'; -import { currentTheme } from '#/tui/theme'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; function stripAnsi(text: string): string { @@ -158,6 +158,90 @@ describe('UserMessageComponent', () => { }); }); + + describe('skillNames', () => { + function withChalkLevel(run: () => void): void { + const previousLevel = chalk.level; + chalk.level = 3; + try { + run(); + } finally { + chalk.level = previousLevel; + } + } + + afterEach(() => { + currentTheme.setPalette(darkColors); + }); + + it('highlights only the listed skill tokens and keeps the text verbatim', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + withChalkLevel(() => { + const component = new UserMessageComponent( + 'please /skill:review this and /skill:security too /unknown https://example.com/a', + [], + undefined, + ['review', 'security'], + ); + + const lines = component.render(120); + const joined = lines.join('\n'); + expect(stripAnsi(joined)).toContain( + 'please /skill:review this and /skill:security too /unknown https://example.com/a', + ); + expect(joined).toContain(currentTheme.boldFg('primary', '/skill:review')); + expect(joined).toContain(currentTheme.boldFg('primary', '/skill:security')); + expect(joined).not.toContain(currentTheme.boldFg('primary', '/unknown')); + expect(joined).toContain(currentTheme.fg('textStrong', 'please ')); + }); + }); + + it('highlights a leading skill command and bare-name aliases', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + withChalkLevel(() => { + const leading = new UserMessageComponent('/skill:review src/app.ts', [], undefined, [ + 'review', + ]).render(80); + expect(leading.join('\n')).toContain(currentTheme.boldFg('primary', '/skill:review')); + expect(stripAnsi(leading.join('\n'))).toContain('/skill:review src/app.ts'); + + const alias = new UserMessageComponent('please /commit this', [], undefined, [ + 'commit', + ]).render(80); + expect(alias.join('\n')).toContain(currentTheme.boldFg('primary', '/commit')); + }); + }); + + it('renders no highlight when skill names are absent or unmatched', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + withChalkLevel(() => { + const plain = new UserMessageComponent('please /skill:review this', []).render(80); + expect(plain.join('\n')).not.toContain(currentTheme.boldFg('primary', '/skill:review')); + + const unmatched = new UserMessageComponent('please /skill:review this', [], undefined, [ + 'security', + ]).render(80); + expect(unmatched.join('\n')).not.toContain(currentTheme.boldFg('primary', '/skill:review')); + }); + }); + + it('re-dyes the highlight from the current theme after a switch', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + withChalkLevel(() => { + const component = new UserMessageComponent('please /skill:review this', [], undefined, [ + 'review', + ]); + const dark = component.render(80).join('\n'); + expect(dark).toContain(chalk.hex(darkColors.primary).bold('/skill:review')); + + currentTheme.setPalette(lightColors); + component.invalidate(); + const light = component.render(80).join('\n'); + expect(light).toContain(chalk.hex(lightColors.primary).bold('/skill:review')); + expect(stripAnsi(light)).toContain('please /skill:review this'); + }); + }); + }); }); describe('userMessageLineHeights', () => { diff --git a/apps/pythinker-code/test/tui/components/panels/notify-panel.test.ts b/apps/pythinker-code/test/tui/components/panels/notify-panel.test.ts index 4f47abbef..b034588f9 100644 --- a/apps/pythinker-code/test/tui/components/panels/notify-panel.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/notify-panel.test.ts @@ -178,6 +178,30 @@ describe('NotifyPanelComponent', () => { expect(render(panel)).toHaveLength(2); }); + it('shows the title above the message and previews the title when folded', () => { + const panel = new NotifyPanelComponent(); + panel.upsert({ + id: 'tc-1', + agentId: 'main', + time: 0, + title: 'Root cause found', + text: 'Two refreshes reuse one token.\n\n- interceptor\n- visibility', + }); + + const lines = render(panel); + const titleRow = lines.findIndex((line) => line.includes('Root cause found')); + const bodyRow = lines.findIndex((line) => line.includes('Two refreshes reuse one token.')); + expect(titleRow).toBeGreaterThan(1); + expect(bodyRow).toBe(titleRow + 2); + expect(lines[titleRow + 1]).toMatch(/^│\s*│$/); + + panel.setEnded(true); + const collapsed = render(panel); + expect(collapsed).toHaveLength(2); + expect(collapsed[1]).toContain('Root cause found'); + expect(collapsed[1]).not.toContain('Two refreshes'); + }); + it('stays expanded when the turn ends while the user is reading, folds on blur', () => { const panel = new NotifyPanelComponent(); panel.upsert(entry('tc-1', 'phase one')); diff --git a/apps/pythinker-code/test/tui/controllers/cache-hint-controller.test.ts b/apps/pythinker-code/test/tui/controllers/cache-hint-controller.test.ts index 14d89073f..ae6d58309 100644 --- a/apps/pythinker-code/test/tui/controllers/cache-hint-controller.test.ts +++ b/apps/pythinker-code/test/tui/controllers/cache-hint-controller.test.ts @@ -33,6 +33,7 @@ function makeHost( ) { const state = { activeDialog: null as string | null, + editorReplacementMounted: false, appState: { model: 'k2', availableModels: { k2: { model: 'kimi-k2', provider: 'openai' } }, @@ -811,4 +812,24 @@ describe('CacheHintController scenario 1 (resume)', () => { ); vi.restoreAllMocks(); }); + + it('yields to a dialog that already owns the editor and hints on the first idle submit', async () => { + getMock.mockResolvedValue(CONFIG); + peekMock.mockReturnValue(CONFIG); + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host, state } = makeHost({ session }); + state.editorReplacementMounted = true; + const controller = new CacheHintController(host); + + await controller.maybeShowOnResume(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + + state.editorReplacementMounted = false; + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(host.track).toHaveBeenCalledWith( + 'cache_hint_shown', + expect.objectContaining({ scene: 'idle' }), + ); + }); }); diff --git a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts index 57e7f4964..2cd7c604a 100644 --- a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts @@ -1,20 +1,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DOUBLE_ESC_WINDOW_MS } from '#/tui/constant/pythinker-tui'; +import { CTRL_C_HINT, DOUBLE_ESC_WINDOW_MS } from '#/tui/constant/pythinker-tui'; import { EditorKeyboardController, type EditorKeyboardHost, } from '#/tui/controllers/editor-keyboard'; -import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; +import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; interface Harness { readonly host: EditorKeyboardHost; readonly editor: Record unknown) | undefined>; + readonly controller: EditorKeyboardController; readonly openUndoSelector: ReturnType; readonly cancelRunningShellCommand: ReturnType; readonly cancelCompaction: ReturnType; readonly btwCancelRunning: ReturnType; readonly btwCloseOrCancel: ReturnType; + readonly footerHint: ReturnType; + readonly stop: ReturnType; readonly survey: { readonly handlePreInput: ReturnType boolean>>; readonly handleSubmit: ReturnType boolean>>; @@ -35,6 +38,8 @@ function createHarness(options: { streamingPhase?: string; isCompacting?: boolea const cancelCompaction = vi.fn(async () => {}); const btwCancelRunning = vi.fn(() => false); const btwCloseOrCancel = vi.fn(() => false); + const footerHint = vi.fn(); + const stop = vi.fn(); const survey = { handlePreInput: vi.fn<(data: string) => boolean>(() => false), handleSubmit: vi.fn<(text: string) => boolean>(() => false), @@ -52,7 +57,7 @@ function createHarness(options: { streamingPhase?: string; isCompacting?: boolea isCompacting: options.isCompacting ?? false, editorCommand: null, }, - footer: { setTransientHint: vi.fn() }, + footer: { setTransientHint: footerHint }, ui: { requestRender: vi.fn() }, }, session, @@ -66,7 +71,7 @@ function createHarness(options: { streamingPhase?: string; isCompacting?: boolea track: vi.fn(), openExternalEditor: vi.fn(), showError: vi.fn(), - stop: vi.fn(), + stop, } as unknown as EditorKeyboardHost; const controller = new EditorKeyboardController( @@ -78,11 +83,14 @@ function createHarness(options: { streamingPhase?: string; isCompacting?: boolea return { host, editor, + controller, openUndoSelector, cancelRunningShellCommand, cancelCompaction, btwCancelRunning, btwCloseOrCancel, + footerHint, + stop, survey, }; } @@ -237,6 +245,27 @@ describe('EditorKeyboardController btw panel priority', () => { }); }); +describe('EditorKeyboardController disposal', () => { + it('consumes every key after dispose so shutdown feedback is not clobbered', () => { + const { editor, controller, footerHint, stop } = createHarness(); + + pressCtrlC(editor); + expect(footerHint).toHaveBeenCalledWith(CTRL_C_HINT); + + controller.dispose(); + footerHint.mockClear(); + + const onPreInput = editor['onPreInput']; + if (onPreInput === undefined) throw new Error('onPreInput handler not installed'); + expect((onPreInput as (data: string) => boolean)('\u0003')).toBe(true); + expect((onPreInput as (data: string) => boolean)('\u001B')).toBe(true); + expect((onPreInput as (data: string) => boolean)('a')).toBe(true); + + expect(stop).not.toHaveBeenCalled(); + expect(footerHint).not.toHaveBeenCalled(); + }); +}); + describe('EditorKeyboardController shell history recall', () => { type Recall = (entry: string, direction: 1 | -1) => string | undefined; type Mock = ReturnType; @@ -438,21 +467,15 @@ describe('EditorKeyboardController Shift-Tab effort cycle', () => { }); }); -/** - * Ctrl-S steering of the TUI queue: plain-text items steer as messages, - * slash-skill items fire as real activations into the running turn (never as - * literal text), grouped inline-skill submissions stay queued for the drain - * path, bash items stay queued — all in queue order. - */ describe('EditorKeyboardController Ctrl-S steering', () => { function createCtrlSHarness(options: { editorText: string; queued: Array>; skillCommandMap?: Map; + pluginCommandMap?: Map; steering?: boolean; }) { - const steerMessage = vi.fn(); - const steerSkillActivation = vi.fn(); + const steerQueuedMessagesIntoRunningTurn = vi.fn(); const updateQueueDisplay = vi.fn(); const setText = vi.fn(); const editor: Record unknown) | undefined> = { @@ -473,10 +496,10 @@ describe('EditorKeyboardController Ctrl-S steering', () => { }, session: { id: 's1' }, skillCommandMap: options.skillCommandMap ?? new Map(), - steerMessage, - steerSkillActivation, - updateQueueDisplay, + pluginCommandMap: options.pluginCommandMap ?? new Map(), + steerQueuedMessagesIntoRunningTurn, isSteeringQueuedMessages: vi.fn(() => options.steering ?? false), + updateQueueDisplay, validateMediaCapabilities: vi.fn(() => true), showError: vi.fn(), track: vi.fn(), @@ -485,10 +508,7 @@ describe('EditorKeyboardController Ctrl-S steering', () => { closeOrCancel: vi.fn(() => false), }, } as unknown as EditorKeyboardHost; - const controller = new EditorKeyboardController( - host, - undefined as unknown as ImageAttachmentStore, - ); + const controller = new EditorKeyboardController(host, new ImageAttachmentStore()); controller.install(); const onCtrlS = editor['onCtrlS']; if (onCtrlS === undefined) throw new Error('onCtrlS handler not installed'); @@ -496,46 +516,39 @@ describe('EditorKeyboardController Ctrl-S steering', () => { host, editor, setText, - steerMessage, - steerSkillActivation, + steerQueuedMessagesIntoRunningTurn, updateQueueDisplay, onCtrlS: onCtrlS as () => void, }; } - it('steers text as a message, skill items as activations, and keeps bash queued', () => { - const { host, steerMessage, steerSkillActivation, updateQueueDisplay, onCtrlS } = - createCtrlSHarness({ - editorText: '', - queued: [ - { text: 'queued text', agentId: 'main' }, - { - text: '/tower status', - agentId: 'main', - mode: 'skill', - skillName: 'tower', - skillArgs: 'status', - }, - { text: '!ls', agentId: 'main', mode: 'bash' }, - ], - }); + it('leaves queued inputs owned by the coordinator until dispatch succeeds', () => { + const queued = [ + { text: 'earlier note', agentId: 'main' }, + { + text: 'check /skill:review', + agentId: 'main', + inlineSkillActivations: [{ skillName: 'review' }], + }, + { text: 'later note', agentId: 'main' }, + { text: '!ls', agentId: 'main', mode: 'bash' }, + ]; + const { host, steerQueuedMessagesIntoRunningTurn, updateQueueDisplay, onCtrlS } = + createCtrlSHarness({ editorText: '', queued }); onCtrlS(); - expect(steerMessage).toHaveBeenCalledWith(host.session, [ - { text: 'queued text', parts: undefined, imageAttachmentIds: undefined }, - ]); - expect(steerSkillActivation).toHaveBeenCalledWith(host.session, 'tower', 'status'); - expect(host.state.queuedMessages).toEqual([{ text: '!ls', agentId: 'main', mode: 'bash' }]); + expect(steerQueuedMessagesIntoRunningTurn).toHaveBeenCalledWith(true); + expect(host.state.queuedMessages).toEqual(queued); expect(updateQueueDisplay).toHaveBeenCalled(); }); - it('ignores Ctrl-S while an automatic queue steer is still in flight', () => { + it('ignores Ctrl-S while a queue steer is still in flight', () => { const queued = [ { text: 'already steering', agentId: 'main' }, { text: 'later text', agentId: 'main' }, ]; - const { host, setText, steerMessage, steerSkillActivation, onCtrlS } = createCtrlSHarness({ + const { host, setText, steerQueuedMessagesIntoRunningTurn, onCtrlS } = createCtrlSHarness({ editorText: 'draft', queued, steering: true, @@ -543,102 +556,47 @@ describe('EditorKeyboardController Ctrl-S steering', () => { onCtrlS(); - expect(steerMessage).not.toHaveBeenCalled(); - expect(steerSkillActivation).not.toHaveBeenCalled(); + expect(steerQueuedMessagesIntoRunningTurn).not.toHaveBeenCalled(); expect(setText).not.toHaveBeenCalled(); expect(host.state.queuedMessages).toEqual(queued); }); - it('steers plain queued messages but keeps grouped inline-skill submissions queued', () => { - const { host, steerMessage, updateQueueDisplay, onCtrlS } = createCtrlSHarness({ - editorText: '', - queued: [ - { text: 'plain note', agentId: 'main' }, - { - text: 'check /skill:review', - agentId: 'main', - inlineSkillActivations: [{ skillName: 'review' }], - }, - ], - }); - - onCtrlS(); - - expect(steerMessage).toHaveBeenCalledWith(host.session, [ - { text: 'plain note', parts: undefined, imageAttachmentIds: undefined }, - ]); - expect(host.state.queuedMessages).toEqual([ - { - text: 'check /skill:review', - agentId: 'main', - inlineSkillActivations: [{ skillName: 'review' }], - }, - ]); - expect(updateQueueDisplay).toHaveBeenCalled(); - }); - - it('stops steering at the first bundle so later messages keep FIFO order', () => { - const { host, steerMessage, onCtrlS } = createCtrlSHarness({ - editorText: '', - queued: [ - { text: 'earlier note', agentId: 'main' }, - { - text: 'check /skill:review', - agentId: 'main', - inlineSkillActivations: [{ skillName: 'review' }], - }, - { text: 'later note', agentId: 'main' }, - ], - }); - - onCtrlS(); - - expect(steerMessage).toHaveBeenCalledWith(host.session, [ - { text: 'earlier note', parts: undefined, imageAttachmentIds: undefined }, - ]); - expect(host.state.queuedMessages).toEqual([ - { - text: 'check /skill:review', - agentId: 'main', - inlineSkillActivations: [{ skillName: 'review' }], - }, - { text: 'later note', agentId: 'main' }, - ]); - }); - - it('steers nothing when a bundle leads the queue', () => { - const { host, steerMessage, onCtrlS } = createCtrlSHarness({ - editorText: '', - queued: [ - { - text: 'check /skill:review', - agentId: 'main', - inlineSkillActivations: [{ skillName: 'review' }], - }, - { text: 'later note', agentId: 'main' }, - ], + it('includes the draft after a leading grouped skill input', () => { + const queued = { + text: 'check /skill:review', + agentId: 'main', + inlineSkillActivations: [{ skillName: 'review' }], + }; + const { host, setText, steerQueuedMessagesIntoRunningTurn, onCtrlS } = createCtrlSHarness({ + editorText: 'later note', + queued: [queued], }); onCtrlS(); - expect(steerMessage).not.toHaveBeenCalled(); - expect(host.state.queuedMessages).toHaveLength(2); + expect(steerQueuedMessagesIntoRunningTurn).toHaveBeenCalledWith(true); + expect(host.state.queuedMessages).toEqual([queued, { text: 'later note' }]); + expect(setText).toHaveBeenCalledWith(''); }); - it('leaves an editor draft with inline skill tokens in the editor for the grouped path', () => { - const { host, setText, steerMessage, onCtrlS } = createCtrlSHarness({ - editorText: 'check /skill:review', - queued: [{ text: 'plain note', agentId: 'main' }], + it.each([ + ['/skill:review src/app.ts', [{ skillName: 'review', args: 'src/app.ts' }]], + ['check /skill:review', [{ skillName: 'review' }]], + ['/skill:review check /skill:review', [{ skillName: 'review' }]], + ])('parses skill draft %s before asking the coordinator to steer', (text, activations) => { + const { host, setText, steerQueuedMessagesIntoRunningTurn, onCtrlS } = createCtrlSHarness({ + editorText: text, + queued: [], skillCommandMap: new Map([['skill:review', 'review']]), }); onCtrlS(); - expect(steerMessage).toHaveBeenCalledWith(host.session, [ - { text: 'plain note', parts: undefined, imageAttachmentIds: undefined }, + expect(steerQueuedMessagesIntoRunningTurn).toHaveBeenCalledWith(true); + expect(host.state.queuedMessages).toEqual([ + { text, inlineSkillActivations: activations }, ]); - expect(setText).not.toHaveBeenCalled(); - expect(host.state.queuedMessages).toEqual([]); + expect(setText).toHaveBeenCalledWith(''); }); }); diff --git a/apps/pythinker-code/test/tui/controllers/notify.test.ts b/apps/pythinker-code/test/tui/controllers/notify.test.ts index e8ee601b1..c981f3cb6 100644 --- a/apps/pythinker-code/test/tui/controllers/notify.test.ts +++ b/apps/pythinker-code/test/tui/controllers/notify.test.ts @@ -144,6 +144,27 @@ describe('NotifyController', () => { expect(h.notifyPanelContainer.children).toEqual([h.notifyPanel]); }); + it('carries the update title to the panel entry and ignores a blank one', () => { + const h = makeHarness(); + h.emit('tool.call.started', { + toolCallId: 'titled', + name: 'NotifyUser', + args: { title: ' Root cause found ', message: 'Two refreshes reuse one token.' }, + }); + h.emit('tool.result', { toolCallId: 'titled', output: 'Update shown to the user.' }); + h.emit('tool.call.started', { + toolCallId: 'blank', + name: 'NotifyUser', + args: { title: ' ', message: 'Untitled detail.' }, + }); + h.emit('tool.result', { toolCallId: 'blank', output: 'Update shown to the user.' }); + + expect(h.notifyPanel.getEntries().map(({ title, text }) => ({ title, text }))).toEqual([ + { title: 'Root cause found', text: 'Two refreshes reuse one token.' }, + { title: undefined, text: 'Untitled detail.' }, + ]); + }); + it.each([ { output: 'Permission denied', isError: true }, { output: 'Update shown to the user.', synthetic: true }, diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts index f08418ab9..b6eab1dc9 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts @@ -18,6 +18,7 @@ function makeStreamingUIStub() { applyBackgroundTaskTerminalStatus: vi.fn(), markSubagentBackgrounded: vi.fn(), setTurnId: vi.fn(), + setStep: vi.fn(), flushNow: vi.fn(), setTodoList: vi.fn(), resetToolUi: vi.fn(), @@ -29,6 +30,7 @@ function makeStreamingUIStub() { function makeSubagentHandler() { const backgroundTasks = new Map(); + const transcriptedTerminal = new Set(); const host = { state: { appState: { availableModels: {} }, @@ -38,15 +40,15 @@ function makeSubagentHandler() { streamingUI: makeStreamingUIStub(), appendTranscriptEntry: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, - surveyController: { notifyToolCallStarted: vi.fn(), notifyToolCallEnded: vi.fn(), notifyCompactionFinished: vi.fn() }, updateActivityPane: vi.fn(), }; const handler = new SubAgentEventHandler(host as never, { backgroundTasks, - backgroundTaskTranscriptedTerminal: new Set(), + backgroundTaskTranscriptedTerminal: transcriptedTerminal, + backgroundTaskTerminalEntries: new Map(), syncBackgroundAgentBadge: vi.fn(), }); - return { handler, backgroundTasks }; + return { handler, backgroundTasks, host, transcriptedTerminal }; } function spawnEvent(subagentId: string, runInBackground: boolean): SubagentLifecycleEvent { @@ -73,6 +75,76 @@ function completedEvent(subagentId: string): SubagentLifecycleEvent { } as unknown as SubagentLifecycleEvent; } +function cancelledEvent(subagentId: string): SubagentLifecycleEvent { + return { + sessionId: 's1', + agentId: 'main', + type: 'subagent.cancelled', + subagentId, + parentToolCallId: `tc-${subagentId}`, + } as unknown as SubagentLifecycleEvent; +} + +describe('SubAgentEventHandler — background agent cancelled transcript', () => { + function agentTask(subagentId: string) { + return { + taskId: `task-${subagentId}`, + kind: 'agent', + agentId: subagentId, + description: `task ${subagentId}`, + status: 'running', + startedAt: 0, + } as never; + } + + it('appends a stopped transcript entry when a background agent is cancelled', () => { + const { handler, backgroundTasks, host, transcriptedTerminal } = makeSubagentHandler(); + backgroundTasks.set('task-a1', agentTask('a1')); + handler.handleLifecycleEvent(spawnEvent('a1', true)); + (host.appendTranscriptEntry as ReturnType).mockClear(); + + handler.handleLifecycleEvent(cancelledEvent('a1')); + + const appended = (host.appendTranscriptEntry as ReturnType).mock.calls.map( + ([entry]) => entry as { content: string }, + ); + expect(appended).toHaveLength(1); + expect(appended[0]!.content).toContain('stopped'); + expect(transcriptedTerminal.has('task-a1')).toBe(true); + expect(handler.backgroundAgentMetadata.has('a1')).toBe(false); + expect(host.streamingUI.applyBackgroundTaskTerminalStatus).toHaveBeenCalledWith({ + agentId: 'a1', + description: 'task a1', + status: 'killed', + }); + }); + + it('does not append a second entry when the task was already transcripted', () => { + const { handler, backgroundTasks, host, transcriptedTerminal } = makeSubagentHandler(); + backgroundTasks.set('task-a1', agentTask('a1')); + transcriptedTerminal.add('task-a1'); + handler.handleLifecycleEvent(spawnEvent('a1', true)); + (host.appendTranscriptEntry as ReturnType).mockClear(); + + handler.handleLifecycleEvent(cancelledEvent('a1')); + + expect(host.appendTranscriptEntry).not.toHaveBeenCalled(); + expect(handler.backgroundAgentMetadata.has('a1')).toBe(false); + }); + + it('delivers a terminal status to a non-dynamic_workflow foreground tool card when a subagent is cancelled', () => { + const { handler, host } = makeSubagentHandler(); + const tc = { onSubagentSpawned: vi.fn(), onSubagentFailed: vi.fn() }; + (host.streamingUI.getToolComponent as ReturnType).mockReturnValue(tc); + handler.handleLifecycleEvent(spawnEvent('a1', false)); + + handler.handleLifecycleEvent(cancelledEvent('a1')); + + expect(tc.onSubagentFailed).toHaveBeenCalledWith({ error: 'Aborted by the user' }); + expect(host.streamingUI.removeToolComponentIfInactive).toHaveBeenCalledWith('tc-a1'); + }); +}); + describe('SubAgentEventHandler — activity record pruning', () => { it('drops the record of a foreground-only subagent at terminal state', () => { const { handler } = makeSubagentHandler(); @@ -112,6 +184,7 @@ describe('SubAgentEventHandler — activity record pruning', () => { }); function makeSessionEventHost() { + const transcriptEntries: unknown[] = []; const host = { state: { appState: { @@ -126,8 +199,9 @@ function makeSessionEventHost() { toolOutputExpanded: false, todoPanel: { getTodos: vi.fn(() => []) }, transcriptContainer: { addChild: vi.fn() }, + transcriptEntries, tasksBrowser: undefined, - footer: { setBackgroundCounts: vi.fn(), setStreamSpeed: vi.fn() }, + footer: { setBackgroundCounts: vi.fn() }, ui: { requestRender: vi.fn() }, }, session: { id: 's1' }, @@ -143,19 +217,24 @@ function makeSessionEventHost() { showNotice: vi.fn(), track: vi.fn(), recordSessionActivity: vi.fn(), + updateActivityPane: vi.fn(), noteStepUsage: vi.fn(), noteCompactionFinished: vi.fn(), - updateActivityPane: vi.fn(), mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), restoreInputText: vi.fn(), - appendTranscriptEntry: vi.fn(), + appendTranscriptEntry: vi.fn((entry: unknown) => { + // Mirror the real host: appended entries are retrievable from the + // transcript window (the anchor scans it for orphaned agent cards). + transcriptEntries.push(entry); + }), + moveTranscriptEntryToEnd: vi.fn(() => true), sendNormalUserInput: vi.fn(), sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, - surveyController: { notifyToolCallStarted: vi.fn(), notifyToolCallEnded: vi.fn(), notifyCompactionFinished: vi.fn() }, tasksBrowserController: { repaint: vi.fn(), refreshOutputViewer: vi.fn() }, + surveyController: { notifySubagentSpawned: vi.fn() }, }; return host as never; } @@ -223,3 +302,348 @@ describe('SessionEventHandler — background.task.terminated', () => { expect(store.get('agent-7')).toBeUndefined(); }); }); + +describe('SessionEventHandler — task-notification turn anchoring', () => { + interface SessionHostMocks { + appendTranscriptEntry: ReturnType; + moveTranscriptEntryToEnd: ReturnType; + } + + function makeHost() { + // makeSessionEventHost returns `never`; recover the mock surface for assertions. + return makeSessionEventHost() as SessionHostMocks & + ConstructorParameters[0]; + } + + function taskTerminatedEvent(taskId: string, kind: 'agent' | 'process', status: string): Event { + return { + sessionId: 's1', + agentId: 'main', + type: 'background.task.terminated', + info: { + taskId, + kind, + agentId: kind === 'agent' ? `${taskId}-agent` : undefined, + description: `task ${taskId}`, + status, + startedAt: 0, + endedAt: 1, + }, + } as unknown as Event; + } + + function taskNotificationTurnEvent(taskId: string, status: string): Event { + return { + sessionId: 's1', + agentId: 'main', + type: 'turn.started', + turnId: 2, + origin: { kind: 'task', taskId, status }, + } as unknown as Event; + } + + function appendedEntries(host: SessionHostMocks) { + return host.appendTranscriptEntry.mock.calls.map( + ([entry]) => entry as { content: string; backgroundAgentStatus?: { phase: string } }, + ); + } + + it('mounts no transcript card when an agent-kind task terminates', () => { + const host = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(taskTerminatedEvent('task-a1', 'agent', 'completed'), vi.fn()); + + // Agent terminal cards are owned by the subagent lifecycle path — the + // task event itself only records the terminal state. + expect(host.appendTranscriptEntry).not.toHaveBeenCalled(); + expect(handler.backgroundTaskTranscriptedTerminal.has('task-a1')).toBe(true); + }); + + it('moves the mounted terminal card to the notification turn instead of mounting a new one', () => { + const host = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(taskTerminatedEvent('task-p1', 'process', 'completed'), vi.fn()); + expect(appendedEntries(host)).toHaveLength(1); + + handler.handleEvent(taskNotificationTurnEvent('task-p1', 'completed'), vi.fn()); + + expect(host.moveTranscriptEntryToEnd).toHaveBeenCalledWith(appendedEntries(host)[0]); + expect(appendedEntries(host)).toHaveLength(1); + }); + + it('mounts a terminal card at the notification turn start when the agent left none', () => { + const host = makeHost(); + const handler = new SessionEventHandler(host); + + // A timed-out agent that emitted no subagent lifecycle event has no + // terminal card to anchor — agent-kind terminations mount none. + handler.handleEvent(taskTerminatedEvent('task-a2', 'agent', 'timed_out'), vi.fn()); + expect(host.appendTranscriptEntry).not.toHaveBeenCalled(); + + handler.handleEvent(taskNotificationTurnEvent('task-a2', 'timed_out'), vi.fn()); + + const appended = appendedEntries(host); + expect(appended).toHaveLength(1); + expect(appended[0]!.backgroundAgentStatus?.phase).toBe('failed'); + expect(host.moveTranscriptEntryToEnd).not.toHaveBeenCalled(); + }); + + it('anchors the only terminal card when completion lands before the task syncs', () => { + const host = makeHost(); + const handler = new SessionEventHandler(host); + + // Spawn-time background agent: the spawn event carries the task id. + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'subagent.spawned', + subagentId: 'agent-1', + subagentName: 'explore', + parentToolCallId: 'tc-agent-1', + description: 'task agent-1', + runInBackground: true, + taskId: 'task-agent-1', + } as unknown as Event, + vi.fn(), + ); + // Completion lands before background.task.started syncs the task table. + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'subagent.completed', + subagentId: 'agent-1', + resultSummary: 'done', + } as unknown as Event, + vi.fn(), + ); + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'background.task.started', + info: { + taskId: 'task-agent-1', + kind: 'agent', + agentId: 'agent-1', + description: 'task agent-1', + status: 'running', + startedAt: 0, + }, + } as unknown as Event, + vi.fn(), + ); + + const terminalCards = (): unknown[] => + host.appendTranscriptEntry.mock.calls + .map(([entry]) => entry) + .filter( + (entry) => + (entry as { backgroundAgentStatus?: { phase: string } }).backgroundAgentStatus + ?.phase === 'completed', + ); + expect(terminalCards()).toHaveLength(1); + + handler.handleEvent(taskNotificationTurnEvent('task-agent-1', 'completed'), vi.fn()); + + // The notification turn moves the mounted card to the turn start instead + // of mounting a second one. + expect(host.moveTranscriptEntryToEnd).toHaveBeenCalledWith(terminalCards()[0]); + expect(terminalCards()).toHaveLength(1); + }); + + it('anchors the orphaned terminal card of an agent that finished before its task registered', () => { + const host = makeHost(); + const handler = new SessionEventHandler(host); + + // Tower-style spawn: the spawn event carries no task id — the task is + // registered only after launch() returns, and the worker can finish + // before that. + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'subagent.spawned', + subagentId: 'agent-t1', + subagentName: 'explore', + parentToolCallId: 'tc-agent-t1', + description: 'task agent-t1', + runInBackground: true, + } as unknown as Event, + vi.fn(), + ); + // The worker terminates before background.task.started syncs the task: + // its terminal card mounts with no task id to register under. + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'subagent.completed', + subagentId: 'agent-t1', + resultSummary: 'done', + } as unknown as Event, + vi.fn(), + ); + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'background.task.started', + info: { + taskId: 'task-agent-t1', + kind: 'agent', + agentId: 'agent-t1', + description: 'task agent-t1', + status: 'running', + startedAt: 0, + }, + } as unknown as Event, + vi.fn(), + ); + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'background.task.terminated', + info: { + taskId: 'task-agent-t1', + kind: 'agent', + agentId: 'agent-t1', + description: 'task agent-t1', + status: 'completed', + startedAt: 0, + endedAt: 1, + }, + } as unknown as Event, + vi.fn(), + ); + + const terminalCards = (): unknown[] => + host.appendTranscriptEntry.mock.calls + .map(([entry]) => entry) + .filter( + (entry) => + (entry as { backgroundAgentStatus?: { phase: string } }).backgroundAgentStatus + ?.phase === 'completed', + ); + expect(terminalCards()).toHaveLength(1); + + handler.handleEvent(taskNotificationTurnEvent('task-agent-t1', 'completed'), vi.fn()); + + // The notification turn reuses the orphaned card — no duplicate mounts. + expect(host.moveTranscriptEntryToEnd).toHaveBeenCalledWith(terminalCards()[0]); + expect(terminalCards()).toHaveLength(1); + expect(host.appendTranscriptEntry).toHaveBeenCalledTimes(2); + }); + + it('does not steal a previous run’s card when a resumed agent shares its agentId', () => { + const host = makeHost(); + const handler = new SessionEventHandler(host); + + // First run: the background agent completes and its terminal card is + // registered under its own task id. + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'subagent.spawned', + subagentId: 'agent-r1', + subagentName: 'explore', + parentToolCallId: 'tc-agent-r1', + description: 'task agent-r1', + runInBackground: true, + taskId: 'task-r1', + } as unknown as Event, + vi.fn(), + ); + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'background.task.started', + info: { + taskId: 'task-r1', + kind: 'agent', + agentId: 'agent-r1', + description: 'task agent-r1', + status: 'running', + startedAt: 0, + }, + } as unknown as Event, + vi.fn(), + ); + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'subagent.completed', + subagentId: 'agent-r1', + resultSummary: 'done', + } as unknown as Event, + vi.fn(), + ); + + const terminalEntries = () => + ( + handler as unknown as { + backgroundTaskTerminalEntries: Map; + } + ).backgroundTaskTerminalEntries; + const firstRunCard = appendedEntries(host).find( + (entry) => entry.backgroundAgentStatus?.phase === 'completed', + ); + expect(firstRunCard).toBeDefined(); + expect(terminalEntries().get('task-r1')).toBe(firstRunCard); + + // The agent is resumed in the background: the new task shares the + // agentId, then times out without mounting a card of its own. + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'background.task.started', + info: { + taskId: 'task-r2', + kind: 'agent', + agentId: 'agent-r1', + description: 'task agent-r1 (resumed)', + status: 'running', + startedAt: 1, + }, + } as unknown as Event, + vi.fn(), + ); + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'background.task.terminated', + info: { + taskId: 'task-r2', + kind: 'agent', + agentId: 'agent-r1', + description: 'task agent-r1 (resumed)', + status: 'timed_out', + startedAt: 1, + endedAt: 2, + }, + } as unknown as Event, + vi.fn(), + ); + + handler.handleEvent(taskNotificationTurnEvent('task-r2', 'timed_out'), vi.fn()); + + // The anchor must leave the first run's card registered and in place, + // and mount the resumed task's own terminal card instead. + expect(host.moveTranscriptEntryToEnd).not.toHaveBeenCalledWith(firstRunCard); + expect(terminalEntries().get('task-r1')).toBe(firstRunCard); + const appended = appendedEntries(host); + const resumedCard = appended.at(-1)!; + expect(resumedCard).not.toBe(firstRunCard); + expect(resumedCard.backgroundAgentStatus?.phase).toBe('failed'); + expect(terminalEntries().get('task-r2')).toBe(resumedCard); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/staging-leases.test.ts b/apps/pythinker-code/test/tui/controllers/staging-leases.test.ts index 0d1d9c070..b6702428a 100644 --- a/apps/pythinker-code/test/tui/controllers/staging-leases.test.ts +++ b/apps/pythinker-code/test/tui/controllers/staging-leases.test.ts @@ -58,6 +58,27 @@ describe('StagingLeaseTracker', () => { }); describe('turn claiming', () => { + it('keeps main and BTW leases separate when their turn ids match', async () => { + const { tracker, deleted, warn } = makeTracker(); + const main = tracker.create([1], [], 'user'); + const btw = tracker.create([2], [], 'user', undefined, 'agent-btw'); + tracker.handleTurnStarted(turnStarted(1, 'user')); + expect(main?.turnId).toBe('1'); + expect(btw?.turnId).toBeUndefined(); + tracker.handleTurnStarted(turnStarted(1, 'user'), 'agent-btw'); + expect(btw?.turnId).toBe('1'); + expect(warn).not.toHaveBeenCalled(); + tracker.handleTurnEnded(turnEnded(1)); + await tracker.drain(); + expect(deleted.fileIds).toEqual(['file-1']); + tracker.handleTurnEnded(turnEnded(1), 'agent-btw'); + await tracker.drain(); + expect(deleted.fileIds).toEqual(['file-1', 'file-2']); + tracker.releaseAll(); + await tracker.drain(); + expect(deleted.fileIds).toEqual(['file-1', 'file-2']); + }); + it('claims the earliest unbound lease of the matching origin', () => { const { tracker } = makeTracker(); const first = tracker.create([], ['/cache/a'], 'user'); diff --git a/apps/pythinker-code/test/tui/controllers/subagent-event-handler.test.ts b/apps/pythinker-code/test/tui/controllers/subagent-event-handler.test.ts index cf73cab68..2b131e310 100644 --- a/apps/pythinker-code/test/tui/controllers/subagent-event-handler.test.ts +++ b/apps/pythinker-code/test/tui/controllers/subagent-event-handler.test.ts @@ -37,6 +37,7 @@ function makeDynamicWorkflowHandler() { const handler = new SubAgentEventHandler(host as never, { backgroundTasks: new Map(), backgroundTaskTranscriptedTerminal: new Set(), + backgroundTaskTerminalEntries: new Map(), syncBackgroundAgentBadge: vi.fn(), }); return { handler, host, requestRender, transcriptContainer, dockChild }; @@ -47,7 +48,7 @@ function dynamicWorkflowComponentOf(transcriptContainer: { addChild: ReturnType< } function lifecycleEvent( - type: 'subagent.spawned' | 'subagent.started' | 'subagent.completed', + type: 'subagent.spawned' | 'subagent.started' | 'subagent.completed' | 'subagent.cancelled', subagentId: string, parentToolCallId: string, ): SubagentLifecycleEvent { @@ -133,6 +134,37 @@ describe('SubAgentEventHandler — dynamic_workflow render batching', () => { }); }); +describe('SubAgentEventHandler — subagent.cancelled', () => { + it('marks a running dynamic_workflow member cancelled instead of leaving it running', () => { + const { handler, transcriptContainer } = makeDynamicWorkflowHandler(); + startDynamicWorkflowWithChild(handler); + const component = dynamicWorkflowComponentOf(transcriptContainer); + + handler.handleLifecycleEvent(lifecycleEvent('subagent.cancelled', 'child-1', 'tc-1')); + + const output = component.render(120).join('\n'); + expect(output).toContain('⊘'); + }); + + it('keeps the batch-level cancelled label when a member cancel event follows', () => { + const { handler, transcriptContainer } = makeDynamicWorkflowHandler(); + startDynamicWorkflowWithChild(handler); + handler.routeChildAgentEvent(childEvent('assistant.delta', 'child-1')); + + handler.handleAgentDynamicWorkflowToolResult( + 'tc-1', + { output: 'The user manually interrupted this subagent batch.' } as never, + true, + ); + handler.handleLifecycleEvent(lifecycleEvent('subagent.cancelled', 'child-1', 'tc-1')); + + const component = dynamicWorkflowComponentOf(transcriptContainer); + const output = component.render(120).join('\n'); + expect(output).toContain('⊘'); + expect(output).toContain('hello'); + }); +}); + describe('SubAgentEventHandler — dynamic_workflow grid height measurement', () => { it('measures the rows after the transcript once per render pass', async () => { const { handler, transcriptContainer, dockChild } = makeDynamicWorkflowHandler(); diff --git a/apps/pythinker-code/test/tui/controllers/survey-controller.test.ts b/apps/pythinker-code/test/tui/controllers/survey-controller.test.ts index 89d7d9ec6..aef71cbd2 100644 --- a/apps/pythinker-code/test/tui/controllers/survey-controller.test.ts +++ b/apps/pythinker-code/test/tui/controllers/survey-controller.test.ts @@ -239,6 +239,99 @@ function trackedEvent(harness: Harness, eventType: string): Record { + it('reports resumed user turns on the long-context survey without changing warmup', async () => { + const harness = createHarness({ + config: () => ({ + ...DEFAULT_SURVEY_POPUP_PAYLOAD, + long_context_survey_threshold: 500, + long_context_probability: 1, + }), + }); + await harness.flush(); + harness.controller.seedUserTurnCount(40); + harness.runTurns(1); + harness.elapse(2000); + + expect(harness.track).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared', user_turn_count: 41 }), + ); + }); + + it('keeps mount warmup independent of historical user turns', async () => { + const harness = createHarness(); + await harness.flush(); + harness.controller.seedUserTurnCount(40); + harness.clock.mono += 600_000; + harness.runTurns(4); + harness.elapse(2000); + expect(harness.track).not.toHaveBeenCalled(); + + harness.runTurns(1); + harness.elapse(2000); + expect(trackedEvent(harness, 'appeared')).toMatchObject({ user_turn_count: 45 }); + }); + + it('omits the cumulative count when resumed history is unknown', async () => { + const harness = createHarness(); + await harness.flush(); + harness.controller.seedUserTurnCount(undefined); + harness.appear(); + + expect(JSON.parse(JSON.stringify(trackedEvent(harness, 'appeared')))).not.toHaveProperty( + 'user_turn_count', + ); + }); + + it.each([ + [0, 1], + [20, 21], + [undefined, undefined], + ])('uses the new history base %s after remounting', async (base, expected) => { + const harness = createHarness({ + config: () => ({ + ...DEFAULT_SURVEY_POPUP_PAYLOAD, + min_time_between_global_feedback_ms: 0, + long_context_survey_threshold: 500, + long_context_probability: 1, + }), + }); + await harness.flush(); + harness.controller.seedUserTurnCount(40); + harness.runTurns(1); + harness.elapse(2000); + expect(trackedEvent(harness, 'appeared')).toMatchObject({ user_turn_count: 41 }); + + harness.controller.reset(); + harness.controller.seedUserTurnCount(base); + await harness.flush(); + harness.track.mockClear(); + harness.runTurns(1); + harness.elapse(2000); + expect(trackedEvent(harness, 'appeared')).toHaveProperty('user_turn_count', expected); + }); + + it.each(['responded', 'abandoned'])( + 'freezes the cumulative count for the %s lifecycle event', + async (eventType) => { + const harness = createHarness(); + await harness.flush(); + harness.controller.seedUserTurnCount(40); + harness.appear(); + harness.controller.seedUserTurnCount(100); + if (eventType === 'responded') { + harness.typeDigit('3'); + harness.elapse(400); + harness.elapse(3000); + } else { + harness.controller.notifyDisplaced(); + } + + expect(trackedEvent(harness, 'appeared')).toMatchObject({ user_turn_count: 45 }); + expect(trackedEvent(harness, eventType)).toMatchObject({ user_turn_count: 45 }); + }, + ); + it('appears once the session clears warmup and reports appeared', async () => { const harness = createHarness(); await harness.flush(); diff --git a/apps/pythinker-code/test/tui/input/image-placeholder.test.ts b/apps/pythinker-code/test/tui/input/image-placeholder.test.ts index 43d887ccf..d6151573a 100644 --- a/apps/pythinker-code/test/tui/input/image-placeholder.test.ts +++ b/apps/pythinker-code/test/tui/input/image-placeholder.test.ts @@ -9,13 +9,14 @@ import { join } from 'node:path'; import { describe, it, expect } from 'vitest'; -import { parseDaemonFileUrl } from '@pymodel/pythinker-code-sdk'; +import { parseDaemonFileUrl, type PromptPart } from '@pymodel/pythinker-code-sdk'; import { PYTHINKER_CODE_HOME_ENV } from '#/constant/app'; import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; import { extractMediaAttachments, makeExtractionResendable, + omitExpiredVideoFileRefs, pendingMediaIngestions, persistOriginalImageSync, refreshExpiringImageFileRefs, @@ -65,7 +66,51 @@ function videoUrlFromParts(parts: unknown[]): string { return part.videoUrl.url; } +describe('omitExpiredVideoFileRefs', () => { + it('omits an expired video only when its skill argument has a cached copy', () => { + const store = new ImageAttachmentStore(); + const expired = store.addVideo('video/mp4', '/tmp/expired.mp4'); + const live = store.addVideo('video/mp4', '/tmp/live.mp4'); + store.completeVideo(expired, { fileId: 'file-expired', fileExpiresAt: Date.now() - 1 }); + store.completeVideo(live, { fileId: 'file-live', fileExpiresAt: Date.now() + 3600_000 }); + const parts: PromptPart[] = [ + { type: 'text', text: 'describe these videos' }, + { type: 'video_url', videoUrl: { url: 'pythinker-file://file-expired' } }, + { type: 'video_url', videoUrl: { url: 'pythinker-file://file-live' } }, + { type: 'video_url', videoUrl: { url: 'pythinker-file://file-uncached' } }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AQ==' } }, + ]; + expect(omitExpiredVideoFileRefs(parts, [expired, live])).toEqual([ + parts[0], parts[2], parts[3], parts[4], + ]); + expect(omitExpiredVideoFileRefs(parts, [])).toEqual(parts); + }); +}); + describe('extractMediaAttachments', () => { + it('uses captured argument media after a reset without retaining a reused attachment id', () => { + const { cleanup } = setupTempCache(); + try { + const store = new ImageAttachmentStore(); + const original = store.addImage(new Uint8Array([1]), 'image/png', 1, 1); + const extraction = extractMediaAttachments(original.placeholder, store); + store.clear(); + const current = store.addImage(new Uint8Array([2]), 'image/png', 1, 1, undefined, 'file-current'); + expect(current.id).toBe(original.id); + const later = store.addImage(new Uint8Array([3]), 'image/png', 1, 1); + const rewrite = rewriteMediaPlaceholders( + `${original.placeholder} ${later.placeholder}`, store, 'plain', extraction.mediaSnapshots, + ); + expect(rewrite.text).toContain(later.placeholder); + expect(rewrite.hasMedia).toBe(true); + expect(readFileSync(rewrite.stagingPaths[0]!)).toEqual(Buffer.from([1])); + expect(rewrite.imageAttachmentIds).toEqual([]); + expect(rewrite.videoAttachmentIds).toEqual([]); + expect(store.takeFileIds([current.id])).toEqual(['file-current']); + } finally { + cleanup(); + } + }); it('returns no parts and hasMedia=false for plain text', () => { const store = new ImageAttachmentStore(); const r = extractMediaAttachments('hello world', store); diff --git a/apps/pythinker-code/test/tui/message-replay.test.ts b/apps/pythinker-code/test/tui/message-replay.test.ts index 292aef846..5463f514a 100644 --- a/apps/pythinker-code/test/tui/message-replay.test.ts +++ b/apps/pythinker-code/test/tui/message-replay.test.ts @@ -4,6 +4,7 @@ import type { AgentReplayRecord, BackgroundTaskInfo, ContentPart, + Event, GoalSnapshot, PromptOrigin, ResumedAgentState, @@ -966,6 +967,9 @@ describe('PythinkerTUI resume message replay', () => { ).toBe(false); expect(driver.sessionEventHandler.backgroundTaskTranscriptedTerminal.has('task-bg-timeout')) .toBe(true); + // An agent-kind task termination mounts no transcript card on its own — + // the subagent lifecycle path owns agent terminal cards, and here it is + // suppressed because the task was already transcripted as timed out. expect( driver.state.transcriptEntries.some( (entry) => entry.backgroundAgentStatus?.phase === 'failed', @@ -1140,6 +1144,27 @@ describe('PythinkerTUI resume message replay', () => { expect(transcript).toContain('real answer'); }); + it('keeps the previous turn’s final answer visible when a task-notification turn follows in replay', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'real prompt' }]), + message('assistant', [{ type: 'text', text: 'real answer' }]), + message('user', [{ type: 'text', text: 'task finished' }], { + origin: { + kind: 'task', + taskId: 'task-1', + status: 'completed', + notificationId: 'ntf-1', + }, + }), + message('assistant', [{ type: 'text', text: 'task report part one' }]), + message('assistant', [{ type: 'text', text: 'task report final' }]), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('task report final'); + expect(transcript).toContain('real answer'); + }); + it('renders cron_missed origin records during replay without exposing raw XML', async () => { const cronMissed = '\n3 one-shot tasks missed while offline\n'; @@ -1167,6 +1192,146 @@ describe('PythinkerTUI resume message replay', () => { ).toEqual(['3 one-shot tasks missed while offline']); }); + it('renders a bundled skill prompt as one user entry without skill cards', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: 'expanded review instructions', + meta: { source: 'skill activation', activationId: 'act-1' }, + } as ContentPart, + { + type: 'text', + text: 'expanded security instructions', + meta: { source: 'skill activation', activationId: 'act-2' }, + } as ContentPart, + { type: 'text', text: 'please /skill:review and /skill:security' }, + ], + { + origin: { + kind: 'user', + skillActivations: [ + { activationId: 'act-1', skillName: 'review' }, + { activationId: 'act-2', skillName: 'security' }, + ], + } as PromptOrigin, + }, + ), + ]); + + const entries = driver.state.transcriptEntries; + expect(entries.map((entry) => entry.kind)).toEqual(['user']); + expect(entries[0]!.content).toBe('please /skill:review and /skill:security'); + expect(entries[0]!.skillNames).toEqual(['review', 'security']); + expect(driver.sessionEventHandler.seenSkillActivationIds.has('act-1')).toBe(true); + expect(driver.sessionEventHandler.seenSkillActivationIds.has('act-2')).toBe(true); + const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('please /skill:review and /skill:security'); + expect(transcript).not.toContain('expanded review instructions'); + expect(transcript).not.toContain('Activated skill'); + }); + + it('strips unmarked leading skill parts of legacy bundled prompts', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { type: 'text', text: 'expanded legacy instructions' }, + { type: 'text', text: 'please /commit' }, + ], + { + origin: { + kind: 'user', + skillActivations: [{ activationId: 'act-3', skillName: 'commit' }], + } as PromptOrigin, + }, + ), + ]); + + const entries = driver.state.transcriptEntries; + expect(entries.map((entry) => entry.kind)).toEqual(['user']); + expect(entries[0]!.content).toBe('please /commit'); + expect(entries[0]!.skillNames).toEqual(['commit']); + const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).not.toContain('expanded legacy instructions'); + }); + + it('replays a steered bundled prompt using the existing turn rule', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'first prompt' }], { origin: { kind: 'user' } }), + message('assistant', [{ type: 'text', text: 'first answer' }]), + message( + 'user', + [ + { + type: 'text', + text: 'expanded review instructions', + meta: { source: 'skill activation', activationId: 'act-steer' }, + } as ContentPart, + { type: 'text', text: 'steered /skill:review note' }, + ], + { + origin: { + kind: 'user', + inTurn: true, + skillActivations: [{ activationId: 'act-steer', skillName: 'review' }], + } as PromptOrigin, + }, + ), + ]); + + const users = driver.state.transcriptEntries.filter((entry) => entry.kind === 'user'); + expect(users.map((entry) => entry.content)).toEqual([ + 'first prompt', + 'steered /skill:review note', + ]); + expect(users[1]!.turnId).not.toBe(users[0]!.turnId); + expect(users[1]!.skillNames).toEqual(['review']); + }); + + it('records live user-slash skill activations without rendering a card', async () => { + const driver = await makeDriver(makeSession([])); + + driver.sessionEventHandler.handleEvent( + { + type: 'skill.activated', + sessionId: 'ses-replay', + agentId: 'main', + activationId: 'act-live-user', + skillName: 'review', + skillArgs: 'src/app.ts', + trigger: 'user-slash', + } as Event, + () => {}, + ); + + expect(driver.state.transcriptEntries).toEqual([]); + expect(driver.sessionEventHandler.seenSkillActivationIds.has('act-live-user')).toBe(true); + }); + + it('keeps rendering live model-tool skill activation cards', async () => { + const driver = await makeDriver(makeSession([])); + + driver.sessionEventHandler.handleEvent( + { + type: 'skill.activated', + sessionId: 'ses-replay', + agentId: 'main', + activationId: 'act-live-model', + skillName: 'review', + trigger: 'model-tool', + } as Event, + () => {}, + ); + + expect(driver.state.transcriptEntries.map((entry) => entry.kind)).toEqual([ + 'skill_activation', + ]); + expect(driver.sessionEventHandler.seenSkillActivationIds.has('act-live-model')).toBe(true); + }); + it('renders user-slash skill activation once without exposing injected prompt text', async () => { const activation = message( 'user', @@ -1183,12 +1348,172 @@ describe('PythinkerTUI resume message replay', () => { ); const driver = await replayIntoDriver([activation, activation]); - const transcript = driver.state.transcriptContainer.render(120).join('\n'); + const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); - expect(transcript).toContain('review'); - expect(transcript).toContain('src/app.ts'); + const entries = driver.state.transcriptEntries; + expect(entries.map((entry) => entry.kind)).toEqual(['user']); + expect(entries[0]!.content).toBe('/skill:review src/app.ts'); + expect(entries[0]!.skillNames).toEqual(['review']); + expect(entries[0]!.skillActivationId).toBe('act-review'); + expect(entries[0]!.skillName).toBe('review'); + expect(entries[0]!.skillArgs).toBe('src/app.ts'); + expect(entries[0]!.skillTrigger).toBe('user-slash'); + expect(transcript).toContain('/skill:review src/app.ts'); expect(transcript).not.toContain('Review the requested file'); - expect(driver.sessionEventHandler.renderedSkillActivationIds.has('act-review')).toBe(true); + expect(transcript).not.toContain('Activated skill'); + expect(driver.sessionEventHandler.seenSkillActivationIds.has('act-review')).toBe(true); + }); + + it('keeps full saved args when reconstructing the canonical skill command', async () => { + const longArgs = 'check ' + 'src/very/long/path/file.ts '.repeat(30).trim(); + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'expanded skill body' }], { + origin: { + kind: 'skill_activation', + activationId: 'act-long', + skillName: 'review', + skillArgs: longArgs, + trigger: 'user-slash', + }, + }), + ]); + + const entries = driver.state.transcriptEntries; + expect(entries.map((entry) => entry.kind)).toEqual(['user']); + expect(entries[0]!.content).toBe(`/skill:review ${longArgs}`); + expect(entries[0]!.content).not.toContain('…'); + }); + + it('reconstructs a bare canonical command when no args were saved', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'expanded skill body' }], { + origin: { + kind: 'skill_activation', + activationId: 'act-bare', + skillName: 'commit', + trigger: 'user-slash', + }, + }), + ]); + + const entries = driver.state.transcriptEntries; + expect(entries.map((entry) => entry.kind)).toEqual(['user']); + expect(entries[0]!.content).toBe('/skill:commit'); + expect(entries[0]!.skillNames).toEqual(['commit']); + }); + + it.each([ + { skillArgs: undefined, expected: '/example' }, + { skillArgs: 'inspect project', expected: '/example inspect project' }, + ])('preserves recorded builtin command syntax in replay ($expected)', async ({ skillArgs, expected }) => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'expanded builtin skill body' }], { + origin: { + kind: 'skill_activation', activationId: 'act-builtin', skillName: 'example', + skillSource: 'builtin', skillArgs, trigger: 'user-slash', + }, + }), + ]); + expect(driver.state.transcriptEntries.filter((entry) => entry.kind === 'user')).toEqual([ + expect.objectContaining({ content: expected, skillNames: ['example'] }), + ]); + }); + + it('prefers explicitly saved caller text over the canonical reconstruction', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { type: 'text', text: 'expanded skill body' }, + { type: 'text', text: '/review please check this carefully' }, + ], + { + origin: { + kind: 'skill_activation', + activationId: 'act-caller', + skillName: 'review', + skillArgs: 'please check this carefully', + trigger: 'user-slash', + }, + }, + ), + ]); + + const entries = driver.state.transcriptEntries; + expect(entries.map((entry) => entry.kind)).toEqual(['user']); + expect(entries[0]!.content).toBe('/review please check this carefully'); + expect(entries[0]!.skillNames).toEqual(['review']); + }); + + it('keeps the skill command when an external activation carried attachments', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { type: 'text', text: 'expanded skill body' }, + { type: 'text', text: 'Captured page evidence' }, + { type: 'image_url', imageUrl: { url: 'pythinker-file://file-evidence' } }, + ] as ContentPart[], + { + origin: { + kind: 'skill_activation', + activationId: 'act-attachments', + skillName: 'review', + skillArgs: 'src/app.ts', + trigger: 'user-slash', + }, + }, + ), + ]); + + const entries = driver.state.transcriptEntries; + expect(entries.map((entry) => entry.kind)).toEqual(['user']); + expect(entries[0]!.content).toBe('/skill:review src/app.ts Captured page evidence[image]'); + expect(entries[0]!.skillNames).toEqual(['review']); + }); + + it('prepends the skill command when an external activation carried only text attachments', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { type: 'text', text: 'expanded skill body' }, + { type: 'text', text: 'Captured page evidence' }, + ], + { + origin: { + kind: 'skill_activation', + activationId: 'act-text-attachments', + skillName: 'review', + skillArgs: 'src/app.ts', + trigger: 'user-slash', + }, + }, + ), + ]); + + const entries = driver.state.transcriptEntries; + expect(entries.map((entry) => entry.kind)).toEqual(['user']); + expect(entries[0]!.content).toBe('/skill:review src/app.ts Captured page evidence'); + expect(entries[0]!.skillNames).toEqual(['review']); + }); + + it('keeps rendering replayed model-tool skill activation cards', async () => { + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'expanded skill body' }], { + origin: { + kind: 'skill_activation', + activationId: 'act-model', + skillName: 'review', + trigger: 'model-tool', + }, + }), + ]); + + const entries = driver.state.transcriptEntries; + expect(entries.map((entry) => entry.kind)).toEqual(['skill_activation']); + expect(entries[0]!.skillName).toBe('review'); + expect(driver.sessionEventHandler.seenSkillActivationIds.has('act-model')).toBe(true); }); it('renders replayed hook results as assistant transcript entries', async () => { @@ -1241,7 +1566,7 @@ describe('PythinkerTUI resume message replay', () => { driver.state.transcriptEntries .filter((entry) => entry.kind === 'user') .map((entry) => entry.content), - ).toEqual(['prompt', 'merged prompt']); + ).toEqual(['prompt', 'merged prompt', '/skill:review src/app.ts']); }); it('renders replayed compaction records as completed compaction blocks', async () => { diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index 07404f01b..39dbe279d 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -1,6 +1,8 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { existsSync } from 'node:fs'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -10,16 +12,19 @@ import { setCapabilities, type TuiMouseEvent, } from '@pymodel/pi-tui'; -import type { - ApprovalRequest, - ApprovalResponse, - Event, - GoalSnapshot, - Session, +import { + createPythinkerHarness, + type ApprovalRequest, + type ApprovalResponse, + type Event, + type GoalSnapshot, + type Session, } from '@pymodel/pythinker-code-sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; +import { CacheHintDialogComponent } from '#/tui/components/dialogs/cache-hint-dialog'; +import * as cacheHintConfig from '#/utils/cache-hint-config'; import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; import { pythinkerCodePluginMarketplaceUrl } from '#/constant/app'; import { BRAILLE_SPINNER_FRAMES } from '#/tui/constant/rendering'; @@ -57,6 +62,7 @@ import type { SurveyController } from '#/tui/controllers/survey-controller'; import { handleFeedbackCommand } from '#/tui/commands/info'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { openUrl } from '#/utils/open-url'; +import { readSurveyLastShownTime } from '#/utils/survey-state-store'; import { createFeedbackArchivePath } from '../../src/feedback/archive'; import { packageCodebase, scanCodebase } from '../../src/feedback/codebase'; import { uploadArchive } from '../../src/feedback/upload'; @@ -120,6 +126,7 @@ function stripSgr(text: string): string { interface MessageDriver { state: TUIState; + btwPanelController: PythinkerTUI['btwPanelController']; surveyController: SurveyController; streamingUI: StreamingUIController; sessionReplay: SessionReplayRenderer; @@ -131,7 +138,7 @@ interface MessageDriver { mcpServers: Map; }; session: unknown; - waitForLazyCreation(): Promise; + waitForSessionAssembly(): Promise; init(): Promise; handleUserInput(text: string): void; toggleToolOutputExpansion(): void; @@ -143,6 +150,9 @@ interface MessageDriver { clearQueuedMessages(): void; closeSession(reason: string): Promise; setSession(session: unknown): Promise; + switchToSession(session: Session, statusMessage: string): Promise; + reloadCurrentSessionView(session: Session, statusMessage: string): Promise; + createNewSession(): Promise; syncRuntimeState(session?: unknown): Promise; getCurrentSessionId(): string; } @@ -249,6 +259,7 @@ function makeSession(overrides: Record = {}) { }, context: { history: [] }, replay: [], + userTurnCount: 0, }, }, })), @@ -621,11 +632,18 @@ describe('PythinkerTUI message flow', () => { expect(harness.createSession).not.toHaveBeenCalled(); - driver.handleUserInput('/skill:my-skill'); + driver.handleUserInput('/skill:my-skill src/app.ts'); await vi.waitFor(() => { - expect(session.activateSkill).toHaveBeenCalledWith('my-skill', ''); + expect(session.promptWithSkills).toHaveBeenCalledWith( + '/skill:my-skill src/app.ts', + [{ name: 'my-skill', args: 'src/app.ts' }], + ); + expect(driver.state.transcriptEntries.filter((entry) => entry.kind === 'user')).toMatchObject([ + { content: '/skill:my-skill src/app.ts' }, + ]); }); + expect(session.activateSkill).not.toHaveBeenCalled(); expect(harness.createSession).toHaveBeenCalledTimes(1); expect(driver.getCurrentSessionId()).toBe('ses-lazy'); }); @@ -825,7 +843,11 @@ describe('PythinkerTUI message flow', () => { expect(session.prompt).not.toHaveBeenCalled(); }); - it('queues an inline-skill prompt while a goal is active (v2 engine)', async () => { + it.each([ + { input: '/skill:review check', skills: [{ name: 'review', args: 'check' }] }, + { input: 'check /skill:review', skills: [{ name: 'review' }] }, + { input: '/skill:review check /skill:review', skills: [{ name: 'review' }] }, + ])('submits a skill prompt at an idle goal boundary ($input)', async ({ input, skills }) => { const session = makeSession({ id: 'ses-lazy', listSkills: vi.fn(async () => [ @@ -852,18 +874,17 @@ describe('PythinkerTUI message flow', () => { await (driver as unknown as { ensureSession(): Promise }).ensureSession(); driver.state.appState.goal = makeActiveGoalSnapshot(); - driver.handleUserInput('check /skill:review'); + driver.state.editor.setText(input); + driver.state.editor.handleInput('\r'); - expect(session.promptWithSkills).not.toHaveBeenCalled(); - expect(driver.state.queuedMessages).toEqual([ - expect.objectContaining({ - text: 'check /skill:review', - inlineSkillActivations: [{ skillName: 'review' }], - }), - ]); + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith(input, skills); + }); + expect(driver.state.queuedMessages).toEqual([]); + expect(driver.state.appState.goal?.status).toBe('active'); }); - it('queues a leading-combo bundle while busy instead of rejecting it (v2 engine)', async () => { + it.each([false, true])('queues a leading-combo bundle while busy instead of rejecting it (goal=%s)', async (goalActive) => { const session = makeSession({ id: 'ses-lazy' }); const startupInput: PythinkerTUIStartupInput = { ...makeStartupInput(), @@ -884,7 +905,8 @@ describe('PythinkerTUI message flow', () => { await ( driver as unknown as { refreshSkillCommands(): Promise } ).refreshSkillCommands(); - driver.state.appState.goal = makeActiveGoalSnapshot(); + if (goalActive) driver.state.appState.goal = makeActiveGoalSnapshot(); + driver.state.appState.streamingPhase = 'waiting'; driver.handleUserInput('/skill:review check this /skill:security'); @@ -897,7 +919,7 @@ describe('PythinkerTUI message flow', () => { ]); }); - it('does not append a user entry when the grouped submission is rejected (v2 engine)', async () => { + it('keeps submitted skill history when the grouped submission is rejected (v2 engine)', async () => { const session = makeSession({ id: 'ses-lazy', listSkills: vi.fn(async () => [ @@ -933,7 +955,9 @@ describe('PythinkerTUI message flow', () => { await vi.waitFor(() => { expect(driver.state.appState.streamingPhase).toBe('idle'); }); - expect(driver.state.transcriptEntries.filter((entry) => entry.kind === 'user')).toHaveLength(0); + expect(driver.state.transcriptEntries.filter((entry) => entry.kind === 'user')).toMatchObject([ + { content: 'please /skill:review', skillNames: ['review'] }, + ]); }); it('renders a bundled replay submission as a single turn', async () => { @@ -1043,20 +1067,13 @@ describe('PythinkerTUI message flow', () => { const turns = groupTurns(driver.state.transcriptEntries); expect(turns).toHaveLength(3); - expect(turns[1]!.entries.map((entry) => entry.kind)).toEqual([ - 'skill_activation', - 'skill_activation', - 'assistant', - 'user', - 'assistant', - ]); - expect(turns[1]!.entries[2]!.hookResult).toBe(true); - expect(turns[1]!.entries[3]!.content).toBe('please /skill:review and /skill:security'); - expect( - turns[1]!.entries.slice(0, 2).map((entry) => entry.bundledWithPrompt), - ).toEqual([true, true]); - expect(turns[2]!.entries.map((entry) => entry.kind)).toEqual(['skill_activation', 'user']); - expect(turns[2]!.entries[1]!.content).toBe('please /commit'); + expect(turns[1]!.entries.map((entry) => entry.kind)).toEqual(['assistant', 'user', 'assistant']); + expect(turns[1]!.entries[0]!.hookResult).toBe(true); + expect(turns[1]!.entries[1]!.content).toBe('please /skill:review and /skill:security'); + expect(turns[1]!.entries[1]!.skillNames).toEqual(['review', 'security']); + expect(turns[2]!.entries.map((entry) => entry.kind)).toEqual(['user']); + expect(turns[2]!.entries[0]!.content).toBe('please /commit'); + expect(stripSgr(renderTranscript(driver))).not.toContain('skill card'); }); it('pages Updates with Ctrl+N and arrow keys while keeping the editor focused', async () => { @@ -1369,7 +1386,7 @@ describe('PythinkerTUI message flow', () => { expect(contents).not.toContain('question 0'); }); - it('appends the user entry after the skill cards for a bundled submission (v2 engine)', async () => { + it('renders an accepted skill prompt once without a user activation card (v2 engine)', async () => { const session = makeSession({ id: 'ses-lazy', listSkills: vi.fn(async () => [ @@ -1419,12 +1436,94 @@ describe('PythinkerTUI message flow', () => { release(); await vi.waitFor(() => { - expect(driver.state.transcriptEntries.map((entry) => entry.kind)).toEqual([ - 'skill_activation', - 'user', - ]); + expect(driver.state.transcriptEntries.map((entry) => entry.kind)).toEqual(['user']); + }); + expect(driver.state.transcriptEntries[0]).toEqual(expect.objectContaining({ + content: 'please /skill:review', skillNames: ['review'], + })); + }); + + it.each(['/skill:review inspect project', 'inspect project /skill:review'])( + 'keeps the skill prompt above turn output while submission is pending (%s)', + async (input) => { + let resolveSkill!: () => void; + const request = new Promise((resolve) => { resolveSkill = resolve; }); + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + promptWithSkills: vi.fn(() => request), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.state.editor.setText(input); + driver.state.editor.handleInput('\r'); + await vi.waitFor(() => { expect(session.promptWithSkills).toHaveBeenCalledOnce(); }); + driver.sessionEventHandler.handleEvent({ + type: 'turn.started', sessionId: session.id, agentId: 'main', turnId: 1, + origin: { kind: 'user' }, + } as Event, () => {}); + driver.sessionEventHandler.handleEvent({ + type: 'assistant.delta', sessionId: session.id, agentId: 'main', turnId: 1, + delta: 'REVIEW-PROGRESS', + } as Event, () => {}); + driver.sessionEventHandler.handleEvent({ + type: 'tool.call.started', sessionId: session.id, agentId: 'main', turnId: 1, + toolCallId: 'tc-review', name: 'Bash', args: { command: 'printf review' }, + } as Event, () => {}); + resolveSkill(); + await vi.waitFor(() => { + expect(driver.state.transcriptEntries.some((entry) => entry.content === input)).toBe(true); + }); + const entries = driver.state.transcriptEntries; + const userIndex = entries.findIndex((entry) => entry.kind === 'user' && entry.content === input); + const outputIndex = entries.findIndex((entry) => entry.kind === 'assistant' || entry.kind === 'tool_call'); + expect(outputIndex).toBeGreaterThanOrEqual(0); + expect(userIndex).toBeLessThan(outputIndex); + expect(entries.filter((entry) => entry.kind === 'user')).toHaveLength(1); + expect(driver.streamingUI.hasActiveToolCall('tc-review')).toBe(true); + }, + ); + + it('keeps the previous conclusion visible when a skill turn starts before submission settles', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await (driver as unknown as { refreshSkillCommands(s: unknown): Promise }).refreshSkillCommands(session); + const emit = (event: Event) => driver.sessionEventHandler.handleEvent(event, () => {}); + driver.appendTranscriptEntry({ + id: 'previous-user', kind: 'user', renderMode: 'plain', content: 'previous prompt', + }); + emit({ type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event); + driver.appendTranscriptEntry({ + id: 'previous-answer', kind: 'assistant', turnId: '1', renderMode: 'markdown', content: 'PREVIOUS-CONCLUSION', + }); + emit({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event); + session.promptWithSkills.mockImplementationOnce(async () => { + emit({ type: 'turn.started', agentId: 'main', turnId: 2, origin: { kind: 'user' } } as Event); + }); + + driver.handleUserInput('/skill:review src/app.ts'); + + await vi.waitFor(() => { + expect(driver.state.transcriptEntries.some((entry) => entry.content === '/skill:review src/app.ts')).toBe(true); + }); + driver.appendTranscriptEntry({ + id: 'review-progress', kind: 'assistant', turnId: '2', renderMode: 'markdown', content: 'REVIEW-PROGRESS', }); - expect(driver.state.transcriptEntries[0]!.bundledWithPrompt).toBe(true); + driver.appendTranscriptEntry({ + id: 'review-answer', kind: 'assistant', turnId: '2', renderMode: 'markdown', content: 'REVIEW-CONCLUSION', + }); + emit({ type: 'turn.ended', agentId: 'main', turnId: 2, reason: 'completed' } as Event); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('PREVIOUS-CONCLUSION'); + expect(transcript).toContain('REVIEW-CONCLUSION'); }); it('serializes concurrent lazy session creation (v2 engine)', async () => { @@ -1570,7 +1669,7 @@ describe('PythinkerTUI message flow', () => { startupInput, ); expect(driver.session).toBeUndefined(); - vi.spyOn(driver, 'waitForLazyCreation').mockRejectedValueOnce(new Error('create failed')); + vi.spyOn(driver, 'waitForSessionAssembly').mockRejectedValueOnce(new Error('create failed')); driver.state.editor.onShiftTab?.(); @@ -3334,10 +3433,20 @@ command = "vim" expect(transcript).not.toContain('review'); }); - it('keeps user-slash skill activations as undo anchors', async () => { - const { driver } = await makeDriver(); - + it('undoes a user skill prompt together with its activation', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('/skill:review'); + await vi.waitFor(() => { expect(driver.state.transcriptEntries).toHaveLength(2); }); driver.sessionEventHandler.handleEvent( { type: 'skill.activated', @@ -3569,6 +3678,402 @@ command = "vim" }); }); + it.each(['enter', 'steer'])('preserves literal leading multiline input and editor tab normalization (%s)', async (delivery) => { + for (const separator of ['\n', '\t']) { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.state.editor.setText(`/skill:review${separator}explain this`); + if (delivery === 'steer') driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.handleInput(delivery === 'enter' ? '\r' : '\u0013'); + if (separator === '\n') { + await vi.waitFor(() => { + expect(delivery === 'enter' ? session.prompt : session.steer).toHaveBeenCalled(); + }); + expect(session.promptWithSkills).not.toHaveBeenCalled(); + expect((delivery === 'enter' ? session.prompt : session.steer).mock.calls[0]?.[0]).toBe( + '/skill:review\nexplain this', + ); + } else { + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalled(); + }); + if (delivery === 'enter') expect(session.promptWithSkills).toHaveBeenCalledWith( + '/skill:review explain this', [{ name: 'review', args: 'explain this' }], + ); + else expect(session.promptWithSkills).toHaveBeenCalledWith( + '/skill:review explain this', [{ name: 'review', args: 'explain this' }], { steerIfActive: true }, + ); + } + } + }); + + it('keeps a pending skill video when another input creates the first session', async () => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const sourcePath = join(process.env['PYTHINKER_CODE_HOME'], 'example.mp4'); + await writeFile(sourcePath, 'example video'); + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver(session, {}, startupInput); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addVideo('video/mp4', sourcePath); + let finishIngestion!: () => void; + attachment.pending = new Promise((resolve) => { + finishIngestion = () => { + imageStore.completeVideo(attachment, { fileId: 'file-first-session' }); + resolve(); + }; + }); + const input = `/skill:review ${attachment.placeholder}`; + driver.state.editor.setText(input); + driver.state.editor.handleInput('\r'); + driver.handleUserInput('create the first session'); + await vi.waitFor(() => { expect(session.prompt).toHaveBeenCalled(); }); + finishIngestion(); + await vi.waitFor(() => { expect(driver.state.queuedMessages).toHaveLength(1); }); + expect(driver.state.queuedMessages[0]?.text).toBe(input); + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event, + (item) => { driver.sendQueuedMessage(session, item); }, + ); + await vi.waitFor(() => { expect(session.promptWithSkills).toHaveBeenCalledOnce(); }); + expect(stripSgr(renderTranscript(driver))).not.toContain('Session changed'); + }); + + it('keeps queued image parts aligned when one stashed skill attachment was removed', async () => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const { driver, session } = await makeDriver(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const first = imageStore.addImage(new Uint8Array([1]), 'image/png', 1, 1, undefined, 'file-first'); + const second = imageStore.addImage(new Uint8Array([2]), 'image/png', 1, 1, undefined, 'file-second'); + const input = `/skill:review ${first.placeholder} ${second.placeholder}`; + const extraction = extractMediaAttachments(input, imageStore); + imageStore.remove(first.id); + driver.state.appState.streamingPhase = 'waiting'; + await ( + driver as unknown as { sendInlineSkillUserInput(t: string, skills: Array<{ skillName: string; args: string }>, e: ExtractionResult): Promise } + ).sendInlineSkillUserInput(input, [{ skillName: 'review', args: `${first.placeholder} ${second.placeholder}` }], extraction); + driver.state.appState.streamingPhase = 'idle'; + driver.sendQueuedMessage(session, driver.state.queuedMessages.shift()!); + await vi.waitFor(() => { expect(session.promptWithSkills).toHaveBeenCalledOnce(); }); + const [parts] = ( + session.promptWithSkills as ReturnType Promise>> + ).mock.calls[0]!; + expect(parts).toEqual([ + { type: 'text', text: '/skill:review ' }, + { type: 'image_url', imageUrl: { url: 'pythinker-file://file-first' } }, + { type: 'image_url', imageUrl: { url: 'pythinker-file://file-second' } }, + ]); + }); + + it.each([ + { action: 'new', existingSession: true }, + { action: 'switch', existingSession: true }, + { action: 'new', existingSession: false }, + { action: 'switch', existingSession: false }, + ])('does not send a pending skill video after a session change ($action, existing=$existingSession)', async ({ action, existingSession }) => { + const listSkills = vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]); + const session = makeSession({ listSkills }); + const nextSession = makeSession({ id: 'ses-next', listSkills }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver(session, { + createSession: vi.fn(async () => nextSession), + }, existingSession ? undefined : startupInput); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addVideo('video/mp4', '/tmp/example.mp4'); + let finishIngestion!: () => void; + attachment.pending = new Promise((resolve) => { + finishIngestion = () => { + imageStore.completeVideo(attachment, { fileId: 'file-old-session' }); + resolve(); + }; + }); + driver.state.editor.setText(`/skill:review ${attachment.placeholder}`); + driver.state.editor.handleInput('\r'); + expect(session.promptWithSkills).not.toHaveBeenCalled(); + if (action === 'new') driver.handleUserInput('/new'); + else await ( + driver as unknown as { switchToSession(s: unknown, status: string): Promise } + ).switchToSession(nextSession, 'Resumed session'); + await vi.waitFor(() => { + expect(driver.getCurrentSessionId()).toBe(nextSession.id); + }); + driver.state.editor.setText('keep this new draft'); + finishIngestion(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(session.promptWithSkills).not.toHaveBeenCalled(); + expect(nextSession.promptWithSkills).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([]); + expect(driver.state.editor.getText()).toBe('keep this new draft'); + expect(stripSgr(renderTranscript(driver))).toContain('Session changed'); + }); + + it.each(['drain', 'steer'])('uses the cached skill video argument instead of an expired queued video part (%s)', async (delivery) => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const sourcePath = join(process.env['PYTHINKER_CODE_HOME'], 'example.mp4'); + await writeFile(sourcePath, 'example video'); + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addVideo('video/mp4', sourcePath); + imageStore.completeVideo(attachment, { + fileId: 'file-expiring-video', fileExpiresAt: Date.now() + 60 * 60 * 1000, + }); + driver.state.appState.streamingPhase = 'waiting'; + const input = `/skill:review describe ${attachment.placeholder}`; + driver.state.editor.setText(input); + driver.state.editor.handleInput('\r'); + expect(driver.state.queuedMessages).toHaveLength(1); + expect(session.promptWithSkills).not.toHaveBeenCalled(); + attachment.fileExpiresAt = Date.now() - 1; + if (delivery === 'drain') { + driver.state.appState.streamingPhase = 'idle'; + driver.sendQueuedMessage(session, driver.state.queuedMessages.shift()!); + } else { + driver.state.editor.handleInput('\u0013'); + } + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledOnce(); + }); + const [parts, skills] = ( + session.promptWithSkills as ReturnType Promise>> + ).mock.calls[0]!; + expect(parts).not.toContainEqual({ + type: 'video_url', videoUrl: { url: 'pythinker-file://file-expiring-video' }, + }); + const args = (skills as Array<{ args: string }>)[0]!.args; + const path = args.match(/Attached video file: (.+?) \(open it with ReadMediaFile\)/)?.[1]; + expect(path).toBeDefined(); + expect(await readFile(path!)).toEqual(Buffer.from('example video')); + expect(driver.state.transcriptEntries).toContainEqual(expect.objectContaining({ + kind: 'user', content: input, + })); + emitTurn(driver, 1); + expect(existsSync(path!)).toBe(true); + await driver.closeSession('test'); + await vi.waitFor(() => { expect(existsSync(path!)).toBe(false); }); + }); + + it.each(['image', 'video'])('preserves skill media arguments when the cache hint starts a new session (%s)', async (kind) => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + vi.stubEnv('CUSTOM_API_BASE_URL', 'https://api.example.test/v1'); + const listSkills = vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]); + const session = makeSession({ listSkills }); + const nextSession = makeSession({ + id: 'ses-next', listSkills, + promptWithSkills: vi.fn(async (_input: unknown, _skills: unknown) => {}), + }); + const { driver } = await makeDriver(session, { + createSession: vi.fn(async () => nextSession), + }); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.state.appState.availableModels = { + k2: { model: 'example-model', provider: 'example-provider', maxContextSize: 100 }, + }; + driver.state.appState.availableProviders = { + 'example-provider': { + type: 'pythinker', + baseUrl: 'https://api.example.test/v1', + oauth: { key: 'pythinker-code', storage: 'file' }, + }, + }; + driver.state.appState.contextTokens = 150000; + const peek = vi.spyOn(cacheHintConfig, 'peekCacheHintConfig').mockReturnValue({ + version: 1, + config: { 'example-model': { min_tokens_to_hint: 100000, cache_duration: 600 } }, + }); + (driver as unknown as { cacheHint: { recordActivity(): void } }).cacheHint.recordActivity(); + const now = vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = kind === 'image' + ? stagedImage(imageStore, 'file-cached-skill') + : imageStore.addVideo('video/mp4', join(process.env['PYTHINKER_CODE_HOME'], 'example.mp4')); + if (attachment.kind === 'video') { + await writeFile(attachment.sourcePath, 'example video'); + imageStore.completeVideo(attachment, { + fileId: 'file-cached-skill', fileExpiresAt: Date.now() + 3600_000, + }); + } + const input = `/skill:review describe ${attachment.placeholder}`; + try { + driver.state.editor.setText(input); + driver.state.editor.handleInput('\r'); + const dialog = driver.state.editorContainer.children[0]; + expect(dialog).toBeInstanceOf(CacheHintDialogComponent); + now.mockRestore(); + dialog!.handleInput!('\u001B[B'); + dialog!.handleInput!('\r'); + await vi.waitFor(() => { + expect(nextSession.promptWithSkills).toHaveBeenCalledOnce(); + }); + + expect(session.promptWithSkills).not.toHaveBeenCalled(); + const [parts, skills] = ( + nextSession.promptWithSkills as ReturnType Promise>> + ).mock.calls[0]!; + expect(parts).toContainEqual(kind === 'image' + ? { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } } + : { type: 'video_url', videoUrl: { url: 'pythinker-file://file-cached-skill' } }); + expect(skills).toEqual([{ name: 'review', args: expect.stringContaining(`Attached ${kind} file:`) }]); + const args = (skills as Array<{ args: string }>)[0]!.args; + expect(args).not.toContain(attachment.placeholder); + const path = args.match(/Attached (?:image|video) file: (.+?) \(open it with ReadMediaFile\)/)?.[1]; + expect(path).toBeDefined(); + expect(await readFile(path!)).toEqual(kind === 'image' ? Buffer.from([0xaa, 0xbb]) : Buffer.from('example video')); + expect(driver.state.transcriptEntries).toContainEqual(expect.objectContaining({ + kind: 'user', content: input, + })); + emitTurn(driver, 1); + expect(existsSync(path!)).toBe(true); + await driver.closeSession('test'); + await vi.waitFor(() => { expect(existsSync(path!)).toBe(false); }); + } finally { + now.mockRestore(); + peek.mockRestore(); + vi.unstubAllEnvs(); + } + }); + + it.each([ + { prefix: '/skill:review', skills: [{ name: 'review', args: expect.stringContaining('Attached video file:') }] }, + { prefix: 'watch /skill:review', skills: [{ name: 'review' }] }, + { prefix: '/skill:review /skill:security', skills: [{ name: 'review' }, { name: 'security' }] }, + ])('waits for a pending video before submitting a skill prompt ($prefix)', async ({ prefix, skills }) => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const sourcePath = join(process.env['PYTHINKER_CODE_HOME'], 'example.mp4'); + await writeFile(sourcePath, 'example video'); + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + { name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' }, + ]), + }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addVideo('video/mp4', sourcePath); + let finishIngestion!: () => void; + attachment.pending = new Promise((resolve) => { + finishIngestion = () => { + imageStore.completeVideo(attachment, { fileId: 'file-skill-video' }); + resolve(); + }; + }); + const input = `${prefix} ${attachment.placeholder}`; + driver.state.editor.setText(input); + driver.state.editor.handleInput('\r'); + + expect(session.promptWithSkills).not.toHaveBeenCalled(); + expect(driver.state.editor.getText()).toBe(''); + expect(stripSgr(renderTranscript(driver))).not.toContain('Failed to prepare'); + finishIngestion(); + + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith( + [ + { type: 'text', text: `${prefix} ` }, + { type: 'video_url', videoUrl: { url: 'pythinker-file://file-skill-video' } }, + ], + skills, + ); + }); + expect(driver.state.transcriptEntries).toContainEqual(expect.objectContaining({ + kind: 'user', content: input, + })); + emitTurn(driver, 1); + await vi.waitFor(() => { + expect(harness.deleteFile).toHaveBeenCalledExactlyOnceWith('file-skill-video'); + }); + }); + + it.each(['', 'keep this draft'])('keeps a timed-out skill video in input history without changing the editor (%s)', async (nextDraft) => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.persistInputHistory = vi.fn(async (text) => { driver.state.editor.addToHistory(text); }); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addVideo('video/mp4', '/tmp/example.mp4'); + let finishIngestion!: () => void; + attachment.pending = new Promise((resolve) => { + finishIngestion = () => { + imageStore.completeVideo(attachment, { fileId: 'file-timed-out' }); + resolve(); + }; + }); + const input = `/skill:review ${attachment.placeholder}`; + vi.useFakeTimers(); + try { + driver.state.editor.setText(input); + driver.state.editor.handleInput('\r'); + driver.state.editor.setText(nextDraft); + await vi.advanceTimersByTimeAsync(1_999); + expect(stripSgr(renderTranscript(driver))).not.toContain('Failed to prepare'); + expect(session.promptWithSkills).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(stripSgr(renderTranscript(driver))).toContain('still uploading'); + expect(driver.state.editor.getText()).toBe(nextDraft); + expect(driver.state.appState.streamingPhase).toBe('idle'); + expect(session.promptWithSkills).not.toHaveBeenCalled(); + finishIngestion(); + await vi.advanceTimersByTimeAsync(0); + expect(session.promptWithSkills).not.toHaveBeenCalled(); + driver.state.editor.handleInput('\u0001'); + driver.state.editor.handleInput('\u001B[A'); + expect(driver.state.editor.getText()).toBe(input); + driver.state.editor.handleInput('\u001B[B'); + expect(driver.state.editor.getText()).toBe(nextDraft); + expect(imageStore.get(attachment.id)?.fileId).toBe('file-timed-out'); + } finally { + finishIngestion(); + vi.useRealTimers(); + } + }); + it('releases staged media exactly once when the prompt dispatch rejects', async () => { const session = makeSession({ prompt: vi.fn(async () => { @@ -3592,6 +4097,30 @@ command = "vim" expect(harness.deleteFile).toHaveBeenCalledTimes(1); }); + it('force-exits instead of waiting for shutdown hooks when Ctrl+C is pressed during stop', async () => { + const { driver } = await makeDriver(); + const stopUiForExit = vi + .spyOn(driver as unknown as { stopUiForExit(): void }, 'stopUiForExit') + .mockImplementation(() => {}); + const onExit = vi.fn(async () => {}); + (driver as unknown as { onExit?: (exitCode?: number) => Promise }).onExit = onExit; + let captured: ((data: string) => { consume?: boolean } | undefined) | undefined; + vi.spyOn(driver.state.ui, 'addInputListener').mockImplementation(((listener: unknown) => { + captured = listener as (data: string) => { consume?: boolean } | undefined; + return () => {}; + }) as never); + vi.spyOn(driver, 'closeSession').mockReturnValue(new Promise(() => {})); + + const stopped = (driver as unknown as { stop(): Promise }).stop(); + stopped.catch(() => {}); + + expect(captured).toBeDefined(); + expect(captured?.('\u0003')).toEqual({ consume: true }); + expect(captured?.('a')).toBeUndefined(); + expect(stopUiForExit).toHaveBeenCalledOnce(); + expect(onExit).toHaveBeenCalledWith(130); + }); + it('releases goal-steered staging media when the running goal turn ends', async () => { const { driver, session, harness } = await makeDriver(); const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; @@ -3667,6 +4196,614 @@ command = "vim" }); }); + it('steers a skill draft with its original text and arguments', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('/skill:review src/app.ts'); + + driver.state.editor.onCtrlS?.(); + + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith( + '/skill:review src/app.ts', + [{ name: 'review', args: 'src/app.ts' }], + { steerIfActive: true }, + ); + }); + expect(session.activateSkill).not.toHaveBeenCalled(); + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.editor.getText()).toBe(''); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it.each(['/skill:review', 'check /skill:review'])( + 'retries a failed skill video from input history (%s)', + async (prefix) => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const sourcePath = join(process.env['PYTHINKER_CODE_HOME'], 'example.mp4'); + await writeFile(sourcePath, 'example video'); + const promptWithSkills = vi.fn(async (_input: unknown) => {}) + .mockRejectedValueOnce(new Error('Skill dispatch failed')) + .mockResolvedValue(undefined); + const session = makeSession({ + promptWithSkills, + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(session: unknown): Promise } + ).refreshSkillCommands(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addVideo('video/mp4', sourcePath); + imageStore.completeVideo(attachment, { fileId: 'file-retry-video' }); + const input = `${prefix} ${attachment.placeholder}`; + driver.persistInputHistory = vi.fn(async (text) => { driver.state.editor.addToHistory(text); }); + driver.state.editor.setText(input); + driver.state.editor.handleInput('\r'); + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledOnce(); + expect(driver.state.appState.streamingPhase).toBe('idle'); + }); + expect(attachment.fileId).toBe('file-retry-video'); + expect(harness.deleteFile).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([]); + driver.state.editor.handleInput('\u001B[A'); + expect(driver.state.editor.getText()).toBe(input); + driver.state.editor.handleInput('\r'); + await vi.waitFor(() => { expect(session.promptWithSkills).toHaveBeenCalledTimes(2); }); + expect(promptWithSkills.mock.calls[1]?.[0]).toContainEqual({ + type: 'video_url', videoUrl: { url: 'pythinker-file://file-retry-video' }, + }); + emitTurn(driver, 1); + await vi.waitFor(() => { + expect(harness.deleteFile).toHaveBeenCalledExactlyOnceWith('file-retry-video'); + }); + expect(attachment.fileId).toBeUndefined(); + }, + ); + + it('recalls a skill preparation failure from input history', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addVideo('video/mp4', '/tmp/clip.mp4'); + const text = `watch /skill:review ${attachment.placeholder}`; + driver.persistInputHistory = vi.fn(async (input) => { driver.state.editor.addToHistory(input); }); + driver.state.editor.setText(text); + driver.state.editor.handleInput('\r'); + + expect(session.promptWithSkills).not.toHaveBeenCalled(); + expect(driver.state.editor.getText()).toBe(''); + expect(driver.state.queuedMessages).toEqual([]); + imageStore.completeVideo(attachment, { fileId: 'file-retried-skill' }); + driver.state.editor.handleInput('\u001B[A'); + expect(driver.state.editor.getText()).toBe(text); + driver.state.editor.handleInput('\r'); + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith([ + { type: 'text', text: 'watch /skill:review ' }, + { type: 'video_url', videoUrl: { url: 'pythinker-file://file-retried-skill' } }, + ], [{ name: 'review' }]); + }); + emitTurn(driver, 1); + await vi.waitFor(() => { expect(harness.deleteFile).toHaveBeenCalledWith('file-retried-skill'); }); + }); + + it.each(['/skill:review file.ts', 'check /skill:review file.ts'])( + 'retries a failed skill from input history without requeuing (%s)', + async (input) => { + const session = makeSession({ + promptWithSkills: vi.fn().mockRejectedValueOnce(new Error('Skill unavailable')).mockResolvedValue(undefined), + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + { name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.persistInputHistory = vi.fn(async (text) => { driver.state.editor.addToHistory(text); }); + driver.state.editor.setText(input); + driver.state.editor.handleInput('\r'); + await vi.waitFor(() => { + expect(driver.state.appState.streamingPhase).toBe('idle'); + expect(stripSgr(renderTranscript(driver))).toContain('Skill unavailable'); + }); + expect(driver.state.queuedMessages).toEqual([]); + expect(driver.state.editor.getText()).toBe(''); + expect(driver.state.transcriptEntries).toContainEqual(expect.objectContaining({ + kind: 'user', content: input, skillNames: ['review'], + })); + driver.state.editor.handleInput('\u001B[A'); + expect(driver.state.editor.getText()).toBe(input); + const edited = input.replace('/skill:review', '/skill:security'); + driver.state.editor.setText(edited); + driver.state.editor.handleInput('\r'); + const skills = input.startsWith('/skill:') + ? [{ name: 'security', args: 'file.ts' }] + : [{ name: 'security' }]; + expect(session.promptWithSkills).toHaveBeenLastCalledWith(edited, skills); + expect(driver.state.queuedMessages).toEqual([]); + }, + ); + + it.each(['enter', 'queued'])('ignores a skill rejection after switching sessions (%s)', async (delivery) => { + let rejectSkill!: (error: unknown) => void; + const held = new Promise((_resolve, reject) => { rejectSkill = reject; }); + const session = makeSession({ + promptWithSkills: vi.fn(() => held), + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + if (delivery === 'queued') { + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + } + driver.state.editor.setText('/skill:review OLD_CHANGE'); + driver.state.editor.handleInput('\r'); + if (delivery === 'queued') { + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event, + (item) => { driver.sendQueuedMessage(session, item); }, + ); + } + await vi.waitFor(() => { expect(session.promptWithSkills).toHaveBeenCalledOnce(); }); + const nextSession = makeSession({ id: 'ses-next' }); + await driver.setSession(nextSession); + driver.state.appState.streamingPhase = 'idle'; + driver.state.editor.setText('NEXT_CHANGE'); + driver.state.editor.handleInput('\r'); + expect(nextSession.prompt).toHaveBeenCalledOnce(); + rejectSkill(new Error('Old skill request cancelled')); + await new Promise((resolve) => setImmediate(resolve)); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.queuedMessages).toEqual([]); + expect(stripSgr(renderTranscript(driver))).not.toContain('Old skill request cancelled'); + }); + + it('keeps a goal continuation active while a queued skill rejection settles', async () => { + let rejectSkill!: (error: unknown) => void; + const held = new Promise((_resolve, reject) => { rejectSkill = reject; }); + const session = makeSession({ + promptWithSkills: vi.fn(() => held), + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.state.editor.setText('/skill:review CHANGE'); + driver.state.editor.handleInput('\r'); + await vi.waitFor(() => { expect(session.promptWithSkills).toHaveBeenCalledOnce(); }); + driver.sessionEventHandler.handleEvent( + { type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'system_trigger', name: 'goal_continuation' } } as Event, + () => {}, + ); + rejectSkill(new Error('Prompt blocked by UserPromptSubmit hook')); + await new Promise((resolve) => setImmediate(resolve)); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.streamingUI.getTurnContext().turnId).toBe('1'); + expect(stripSgr(renderTranscript(driver))).toContain('Prompt blocked by UserPromptSubmit hook'); + }); + + it('keeps a skill eligible for automatic dispatch when compaction defers Ctrl-S', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + driver.handleUserInput('first'); + driver.handleUserInput('/skill:review delayed'); + vi.mocked(session.steer).mockImplementation(async () => { + driver.state.appState.isCompacting = true; + }); + driver.state.editor.onCtrlS?.(); + await new Promise((resolve) => setImmediate(resolve)); + expect(session.steer).toHaveBeenCalledOnce(); + expect(session.promptWithSkills).not.toHaveBeenCalled(); + driver.state.appState.isCompacting = false; + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event, + (item) => { driver.sendQueuedMessage(session, item); }, + ); + await vi.waitFor(() => { expect(session.promptWithSkills).toHaveBeenCalledOnce(); }); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it('keeps the ordinary queued tail eligible after a skill failure', async () => { + let rejectSkill!: (error: unknown) => void; + const held = new Promise((_resolve, reject) => { rejectSkill = reject; }); + const promptWithSkills = vi.fn(() => held); + const session = makeSession({ + promptWithSkills, + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + driver.handleUserInput('/skill:review FIRST'); + driver.handleUserInput('QUEUED_TAIL'); + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event, + (item) => { driver.sendQueuedMessage(session, item); }, + ); + await vi.waitFor(() => { expect(promptWithSkills).toHaveBeenCalledOnce(); }); + driver.handleUserInput('OUTSIDE'); + rejectSkill(new Error('Skill unavailable')); + await vi.waitFor(() => { expect(driver.state.appState.streamingPhase).toBe('idle'); }); + driver.state.editor.setText('NEXT'); + driver.state.editor.handleInput('\r'); + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 2, reason: 'completed' } as Event, + (item) => { driver.sendQueuedMessage(session, item); }, + ); + await vi.waitFor(() => { expect(session.prompt).toHaveBeenCalledTimes(2); }); + expect(session.prompt).toHaveBeenLastCalledWith('QUEUED_TAIL', { promptId: undefined }); + expect(driver.state.queuedMessages.map((item) => item.text)).toEqual(['OUTSIDE']); + expect(promptWithSkills).toHaveBeenCalledOnce(); + }); + + it('keeps queued bash dispatch unchanged after a skill rejection', async () => { + let rejectSkill!: (error: unknown) => void; + const held = new Promise((_resolve, reject) => { rejectSkill = reject; }); + const promptWithSkills = vi.fn(() => held); + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ + promptWithSkills, + runShellCommand, + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + driver.handleUserInput('/skill:review FIRST'); + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + driver.handleUserInput('echo BASH_TAIL'); + driver.state.appState.inputMode = 'prompt'; + driver.state.editor.inputMode = 'prompt'; + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event, + (item) => { driver.sendQueuedMessage(session, item); }, + ); + await vi.waitFor(() => { expect(promptWithSkills).toHaveBeenCalledOnce(); }); + rejectSkill(new Error('Skill unavailable')); + await vi.waitFor(() => { expect(driver.state.appState.streamingPhase).toBe('idle'); }); + driver.state.editor.setText('NEXT'); + driver.state.editor.handleInput('\r'); + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 2, reason: 'completed' } as Event, + (item) => { driver.sendQueuedMessage(session, item); }, + ); + await vi.waitFor(() => { expect(runShellCommand).toHaveBeenCalledOnce(); }); + expect(runShellCommand).toHaveBeenCalledWith('echo BASH_TAIL', expect.objectContaining({ commandId: expect.any(String) })); + expect(session.prompt).toHaveBeenCalledOnce(); + expect(promptWithSkills).toHaveBeenCalledOnce(); + expect(driver.state.queuedMessages.map((item) => item.text)).toEqual([]); + }); + + it('keeps dispatched skill media alive when the remaining queue is discarded', async () => { + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + const session = makeSession({ + promptWithSkills: vi.fn(() => held), + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa]), 'image/png', 1, 1); + attachment.fileId = 'file-dispatched-skill'; + attachment.fileExpiresAt = Date.now() + 60 * 60 * 1000; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + driver.handleUserInput(`check /skill:review ${attachment.placeholder}`); + driver.handleUserInput('discard this later note'); + driver.state.editor.onCtrlS?.(); + expect(session.promptWithSkills).toHaveBeenCalledTimes(1); + + (driver as unknown as { clearQueuedMessages(): void }).clearQueuedMessages(); + + expect(harness.deleteFile).not.toHaveBeenCalled(); + release(); + await vi.waitFor(() => { expect(driver.state.queuedMessages).toEqual([]); }); + await new Promise((resolve) => setImmediate(resolve)); + expect(session.steer).not.toHaveBeenCalled(); + expect(harness.deleteFile).not.toHaveBeenCalled(); + emitTurn(driver, 1); + await vi.waitFor(() => { expect(harness.deleteFile).toHaveBeenCalledWith('file-dispatched-skill'); }); + expect(harness.deleteFile).toHaveBeenCalledTimes(1); + }); + + it('keeps a skill steer media lease unclaimed by an untracked turn it cannot merge into', async () => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-goal-skill'); + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + driver.state.editor.setText(`check /skill:review ${attachment.placeholder}`); + + driver.state.editor.onCtrlS?.(); + + await vi.waitFor(() => { expect(session.promptWithSkills).toHaveBeenCalledOnce(); }); + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event, + (item) => { driver.sendQueuedMessage(session, item); }, + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(harness.deleteFile).not.toHaveBeenCalled(); + + emitTurn(driver, 2); + await vi.waitFor(() => { + expect(harness.deleteFile).toHaveBeenCalledExactlyOnceWith('file-goal-skill'); + }); + }); + + it('restores idle when skill preparation fails after the previous turn ends during Ctrl-S delivery', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const missing = imageStore.addVideo('video/quicktime', '/tmp/pythinker-missing-source.mov'); + imageStore.completeVideo(missing, { fileId: 'file-ended-turn-skill' }); + const text = `/skill:review look ${missing.placeholder}`; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + driver.handleUserInput('accepted prefix'); + driver.handleUserInput(text); + driver.handleUserInput('later'); + vi.mocked(session.steer).mockImplementation(async () => { + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event, + () => {}, + ); + }); + + driver.state.editor.onCtrlS?.(); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('Failed to prepare media attachment'); + }); + expect(driver.state.appState.streamingPhase).toBe('idle'); + expect(driver.state.livePane.mode).toBe('idle'); + expect(driver.state.queuedMessages).toEqual([]); + expect(session.steer).toHaveBeenCalledTimes(2); + expect(session.steer).toHaveBeenLastCalledWith('later'); + expect(session.promptWithSkills).not.toHaveBeenCalled(); + expect(session.prompt).not.toHaveBeenCalled(); + }); + + it.each(['/skill:review keep-me', 'check /skill:review keep-me'])('keeps submitted skill input after Ctrl-S rejection (%s)', async (input) => { + const session = makeSession({ + promptWithSkills: vi.fn(async () => { throw new Error('Skill unavailable'); }), + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + driver.handleUserInput('accepted prefix'); + driver.handleUserInput(input); + driver.handleUserInput('later'); + vi.mocked(session.steer).mockImplementation(async () => { + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event, + () => {}, + ); + }); + + driver.state.editor.onCtrlS?.(); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('Skill unavailable'); + }); + expect(driver.state.appState.streamingPhase).toBe('idle'); + expect(driver.state.livePane.mode).toBe('idle'); + expect(driver.state.queuedMessages).toEqual([]); + expect(driver.state.transcriptEntries).toContainEqual(expect.objectContaining({ + kind: 'user', content: input, skillNames: ['review'], + })); + expect(driver.state.transcriptEntries).toContainEqual(expect.objectContaining({ + kind: 'user', content: 'later', + })); + expect(session.steer).toHaveBeenCalledTimes(2); + expect(session.steer).toHaveBeenLastCalledWith('later'); + expect(session.promptWithSkills).toHaveBeenCalledOnce(); + expect(session.prompt).not.toHaveBeenCalled(); + }); + + it('keeps submitted ordinary Ctrl-S input when the request fails', async () => { + const session = makeSession({ + steer: vi.fn(async () => { throw new Error('Input unavailable'); }), + }); + const { driver } = await makeDriver(session); + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + driver.state.editor.setText('keep this input'); + + driver.state.editor.onCtrlS?.(); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('Input unavailable'); + }); + expect(session.steer).toHaveBeenCalledWith('keep this input'); + expect(driver.state.queuedMessages).toEqual([]); + expect(driver.state.transcriptEntries).toContainEqual(expect.objectContaining({ + kind: 'user', content: 'keep this input', + })); + }); + + it('serializes Ctrl-S inputs without blocking later submissions after a skill failure', async () => { + let rejectSkill!: (error: unknown) => void; + const held = new Promise((_resolve, reject) => { rejectSkill = reject; }); + const sent: string[] = []; + const promptWithSkills = vi.fn(async (input: unknown) => { sent.push(String(input)); }) + .mockImplementationOnce((input: unknown) => { + sent.push(String(input)); + return held; + }); + const session = makeSession({ + promptWithSkills, + steer: vi.fn(async (input: unknown) => { sent.push(String(input)); }), + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + { name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + driver.handleUserInput('first'); + driver.handleUserInput('check /skill:review'); + driver.handleUserInput('later'); + driver.state.editor.setText('draft /skill:security'); + driver.state.editor.onCtrlS?.(); + await vi.waitFor(() => { expect(promptWithSkills).toHaveBeenCalledOnce(); }); + expect(sent).toEqual(['first', 'check /skill:review']); + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('newer'); + expect(session.prompt).not.toHaveBeenCalled(); + rejectSkill(new Error('Skill unavailable')); + await vi.waitFor(() => { + expect(sent).toEqual(['first', 'check /skill:review', 'later', 'draft /skill:security']); + expect(driver.state.queuedMessages.map((item) => item.text)).toEqual(['newer']); + }); + expect(stripSgr(renderTranscript(driver))).toContain('Skill unavailable'); + expect(driver.state.transcriptEntries).toContainEqual(expect.objectContaining({ + kind: 'user', content: 'check /skill:review', skillNames: ['review'], + })); + driver.sessionEventHandler.handleEvent( + { type: 'turn.started', agentId: 'main', turnId: 2, origin: { kind: 'user' } } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 2, reason: 'completed' } as Event, + (item) => { driver.sendQueuedMessage(session, item); }, + ); + await vi.waitFor(() => { expect(session.prompt).toHaveBeenCalledOnce(); }); + expect(session.prompt).toHaveBeenCalledWith('newer', { promptId: undefined }); + expect(promptWithSkills).toHaveBeenCalledTimes(2); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it.each(['enter', 'ctrl-s'])('keeps skill media args and releases uploads on consumption (%s)', async (submission) => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa]), 'image/png', 1, 1); + attachment.fileId = 'file-skill-media'; + attachment.fileExpiresAt = Date.now() + 60 * 60 * 1000; + const text = `/skill:review look ${attachment.placeholder}`; + if (submission === 'ctrl-s') { + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + driver.state.editor.setText(text); + driver.state.editor.onCtrlS?.(); + } else { + driver.handleUserInput(text); + } + + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalled(); + }); + const [input, skills] = vi.mocked(session.promptWithSkills).mock.calls[0] as unknown as [ + unknown, + Array<{ name: string; args: string }>, + ]; + expect(input).toEqual([ + { type: 'text', text: '/skill:review look ' }, + { type: 'image_url', imageUrl: { url: 'pythinker-file://file-skill-media' } }, + ]); + expect(skills[0]?.name).toBe('review'); + expect(skills[0]?.args).toMatch(/^look Attached image file: .+\.png \(open it with ReadMediaFile\)$/); + expect(skills[0]?.args).not.toContain(attachment.placeholder); + expect(driver.state.transcriptEntries).toContainEqual( + expect.objectContaining({ kind: 'user', content: text, skillNames: ['review'] }), + ); + expect(harness.deleteFile).not.toHaveBeenCalled(); + + emitTurn(driver, 1); + + await vi.waitFor(() => { + expect(harness.deleteFile).toHaveBeenCalledWith('file-skill-media'); + }); + expect(harness.deleteFile).toHaveBeenCalledTimes(1); + }); + it('queues editor input instead of prompting while a turn is already streaming', async () => { const { driver, session, harness } = await makeDriver(); driver.state.appState.streamingPhase = 'waiting'; @@ -3680,7 +4817,7 @@ command = "vim" expect(harness.track).toHaveBeenCalledWith('input_queue', undefined); }); - it('queues a slash-skill activation while a turn is streaming (like any other input) and activates on drain', async () => { + it('queues a leading skill prompt while a turn is streaming and submits it on drain', async () => { const session = makeSession({ listSkills: vi.fn(async () => [ { @@ -3706,9 +4843,7 @@ command = "vim" { text: '/demo refactor auth and ui', agentId: 'main', - mode: 'skill', - skillName: 'demo', - skillArgs: 'refactor auth and ui', + inlineSkillActivations: [{ skillName: 'demo', args: 'refactor auth and ui' }], }, ]); expect(harness.track).toHaveBeenCalledWith('input_queue', undefined); @@ -3718,10 +4853,42 @@ command = "vim" driver.state.queuedMessages = []; driver.sendQueuedMessage(session, queued); - expect(session.activateSkill).toHaveBeenCalledWith('demo', 'refactor auth and ui'); + expect(session.promptWithSkills).toHaveBeenCalledWith( + '/demo refactor auth and ui', [{ name: 'demo', args: 'refactor auth and ui' }], + ); + expect(session.activateSkill).not.toHaveBeenCalled(); + }); + + it('uses the existing media queue policy after the selected model changes', async () => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await (driver as unknown as { refreshSkillCommands(s: unknown): Promise }).refreshSkillCommands(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-queued-model-change'); + driver.state.appState.streamingPhase = 'waiting'; + driver.handleUserInput(`inspect /skill:review ${attachment.placeholder}`); + expect(driver.state.queuedMessages).toHaveLength(1); + driver.state.appState.model = 'text-only'; + driver.state.appState.availableModels = { + 'text-only': { provider: 'example', model: 'example-model', maxContextSize: 4096, capabilities: [] }, + }; + driver.state.appState.streamingPhase = 'idle'; + const queued = driver.state.queuedMessages.shift()!; + + driver.sendQueuedMessage(session, queued); + + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith(expect.any(Array), [{ name: 'review' }]); + }); + expect(driver.state.queuedMessages).toEqual([]); }); - it('queues a slash-skill activation while compacting and activates it on drain', async () => { + it('queues a leading skill prompt while compacting and submits it on drain', async () => { const session = makeSession({ listSkills: vi.fn(async () => [ { @@ -3747,9 +4914,7 @@ command = "vim" { text: '/demo refactor auth and ui', agentId: 'main', - mode: 'skill', - skillName: 'demo', - skillArgs: 'refactor auth and ui', + inlineSkillActivations: [{ skillName: 'demo', args: 'refactor auth and ui' }], }, ]); expect(driver.state.queueContainer.children.length).toBeGreaterThan(0); @@ -3760,7 +4925,10 @@ command = "vim" driver.state.queuedMessages = []; driver.sendQueuedMessage(session, queued); - expect(session.activateSkill).toHaveBeenCalledWith('demo', 'refactor auth and ui'); + expect(session.promptWithSkills).toHaveBeenCalledWith( + '/demo refactor auth and ui', [{ name: 'demo', args: 'refactor auth and ui' }], + ); + expect(session.activateSkill).not.toHaveBeenCalled(); }); it('steers fresh input while a goal is active even when the streaming phase is idle', async () => { @@ -4299,6 +5467,231 @@ command = "vim" expect(session.cancel).toHaveBeenCalledTimes(1); }); + it('returns to idle and allows /clear after cancelling ReadMediaFile', async () => { + const nextSession = makeSession({ id: 'ses-2' }); + const { driver, session, harness } = await makeDriver(); + harness.createSession.mockResolvedValueOnce(nextSession); + driver.sessionEventHandler.handleEvent( + { type: 'turn.started', agentId: 'main', sessionId: 'ses-1', turnId: 0, origin: { kind: 'user' } } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { type: 'turn.step.started', agentId: 'main', sessionId: 'ses-1', turnId: 0, step: 6 } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 0, + toolCallId: 'tool_LSp6cwjJVBvL6Zzej7YMboRi', + name: 'ReadMediaFile', + args: { path: 'analysis/full_small.mp4' }, + display: { kind: 'file_io', operation: 'read', path: '/tmp/full_small.mp4' }, + } as Event, + () => {}, + ); + expect(driver.state.activitySpinner).not.toBeNull(); + + session.cancel.mockImplementation(async () => { + driver.sessionEventHandler.handleEvent( + { + type: 'tool.result', + agentId: 'main', + sessionId: 'ses-1', + turnId: 0, + toolCallId: 'tool_LSp6cwjJVBvL6Zzej7YMboRi', + output: 'The user manually interrupted "ReadMediaFile".', + isError: true, + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.interrupted', + agentId: 'main', + sessionId: 'ses-1', + turnId: 0, + step: 6, + reason: 'aborted', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 0, + reason: 'cancelled', + durationMs: 53847, + traceId: '87ba9125dc01214ef86100ac03bd4fd9', + } as Event, + () => {}, + ); + }); + + driver.state.editor.onEscape?.(); + + expect(session.cancel).toHaveBeenCalledTimes(1); + expect(driver.state.appState.streamingPhase).toBe('idle'); + expect(driver.state.livePane.mode).toBe('idle'); + expect(driver.state.activitySpinner).toBeNull(); + + driver.handleUserInput('/clear'); + + await vi.waitFor(() => { + expect(driver.getCurrentSessionId()).toBe('ses-2'); + }); + expect(driver.state.appState.streamingPhase).toBe('idle'); + expect(driver.state.livePane.mode).toBe('idle'); + expect(driver.state.activitySpinner).toBeNull(); + }); + + it('aborts an in-flight video upload and recovers through /clear with the real engine', async () => { + const homeDir = await makeTempHome(); + const workDir = await mkdtemp(join(tmpdir(), 'pythinker-code-tui-video-')); + tempDirs.push(workDir); + await writeFile( + join(workDir, 'clip.mp4'), + Buffer.from('00000020667479706d703432000000006d70343269736f6d', 'hex'), + ); + + let notifyUploadStarted!: () => void; + const uploadStarted = new Promise((resolve) => { + notifyUploadStarted = resolve; + }); + let uploadAborted = false; + const server = createServer((req, res) => { + void (async () => { + for await (const _chunk of req) { + } + if (req.url === '/v1/files') { + notifyUploadStarted(); + res.on('close', () => { + if (!res.writableEnded) uploadAborted = true; + }); + return; + } + if (req.url === '/v1/chat/completions') { + const chunks = [ + { + id: 'chatcmpl-mock', + object: 'chat.completion.chunk', + created: 1, + model: 'mock', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: 'call_video_1', + type: 'function', + function: { name: 'ReadMediaFile', arguments: '{"path":"clip.mp4"}' }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-mock', + object: 'chat.completion.chunk', + created: 1, + model: 'mock', + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + usage: { prompt_tokens: 10, completion_tokens: 4, total_tokens: 14 }, + }, + ]; + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.end(`${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join('')}data: [DONE]\n\n`); + return; + } + res.writeHead(404); + res.end(); + })().catch(() => { + res.destroy(); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`; + await writeFile( + join(homeDir, 'config.toml'), + ` +default_model = "stub" + +[providers.stub] +type = "pythinker" +base_url = "${baseUrl}" +api_key = "test-key" + +[models.stub] +provider = "stub" +model = "stub" +max_context_size = 262144 +capabilities = ["image_in", "video_in"] +`, + 'utf-8', + ); + + let harness: ReturnType | undefined; + let driver: MessageDriver | undefined; + try { + harness = createPythinkerHarness({ + homeDir, + identity: { productName: 'pythinker-code-cli', version: '0.0.0-test', platform: 'pythinker_code_cli' }, + }); + const startupInput = { ...makeStartupInput(), workDir }; + startupInput.cliOptions.model = 'stub'; + driver = new PythinkerTUI(harness, startupInput) as unknown as MessageDriver; + vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); + driver.persistInputHistory = vi.fn(async () => {}); + await driver.init(); + const session = await harness.createSession({ workDir }); + await driver.setSession(session); + await driver.syncRuntimeState(session); + driver.sessionEventHandler.startSubscription(); + + driver.handleUserInput('watch clip.mp4'); + await uploadStarted; + driver.state.editor.onEscape?.(); + + await vi.waitFor(() => { + expect(driver!.state.appState.streamingPhase).toBe('idle'); + }); + await vi.waitFor(() => { + expect(uploadAborted).toBe(true); + }); + expect(driver.state.activitySpinner).toBeNull(); + const previousSessionId = driver.getCurrentSessionId(); + + driver.handleUserInput('/clear'); + + await vi.waitFor(() => { + expect(driver!.getCurrentSessionId()).not.toBe(previousSessionId); + }); + expect(driver.state.appState.streamingPhase).toBe('idle'); + expect(driver.state.livePane.mode).toBe('idle'); + expect(driver.state.activitySpinner).toBeNull(); + } finally { + driver?.state.activitySpinner?.stop(); + await driver?.closeSession('test').catch(() => {}); + await harness?.close(); + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); + } + }, 20_000); + it('clears streaming editor text before cancelling the active turn on Ctrl-C', async () => { const { driver, session } = await makeDriver(); @@ -4407,9 +5800,11 @@ command = "vim" driver.state.editor.onCtrlS?.(); expect(session.steer).toHaveBeenCalledWith('focus on tests'); - expect(driver.state.queuedMessages).toEqual([ - { text: 'ls', agentId: 'main', mode: 'bash' }, - ]); + await vi.waitFor(() => { + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); + }); }); it('does not steer while a shell command is running', async () => { @@ -4512,7 +5907,7 @@ command = "vim" { type: 'text', text: 'look ' }, { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, ]); - expect(driver.state.queuedMessages).toEqual([]); + await vi.waitFor(() => { expect(driver.state.queuedMessages).toEqual([]); }); }); it('releases every queued use of shared media after a batched steer', async () => { @@ -4711,20 +6106,27 @@ command = "vim" ]); }); - it('shows an error instead of throwing when skill media materialization fails', async () => { - const session = makeSession(); + it('reports failed skill media argument materialization without requeuing', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'test', description: 'Test skill', path: '/tmp/test', source: 'user' }, + ]), + }); const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; const missing = imageStore.addVideo('video/quicktime', '/tmp/pythinker-missing-source.mov'); + imageStore.completeVideo(missing, { fileId: 'file-missing-source' }); + const text = `/skill:test look ${missing.placeholder}`; - ( - driver as unknown as { - sendSkillActivation(s: unknown, name: string, args: string): void; - } - ).sendSkillActivation(session, 'test', `look ${missing.placeholder}`); + driver.handleUserInput(text); + expect(session.promptWithSkills).not.toHaveBeenCalled(); expect(session.activateSkill).not.toHaveBeenCalled(); expect(stripSgr(renderTranscript(driver))).toContain('Failed to prepare media attachment'); + expect(driver.state.queuedMessages).toEqual([]); }); it('shows an error instead of throwing when plugin command media materialization fails', async () => { @@ -4919,23 +6321,154 @@ command = "vim" coalescedCount: 1, stale: false, }, - }); + }); + + const transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('Scheduled reminder fired'); + expect(transcript).toContain('* * * * *'); + expect(transcript).toContain('Remind the user: this is a once-per-minute reminder'); + expect(transcript).not.toContain(' { + const { driver } = await makeDriver(); + const emit = (event: Event) => driver.sessionEventHandler.handleEvent(event, () => {}); + let entrySeq = 0; + const entry = (kind: 'user' | 'assistant', content: string, turnId?: string) => { + entrySeq += 1; + driver.appendTranscriptEntry({ + id: `cron-fold-${entrySeq}`, + kind, + turnId, + renderMode: kind === 'assistant' ? 'markdown' : 'plain', + content, + }); + }; + + entry('user', 'what is the answer?'); + emit({ type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event); + entry('assistant', 'working on it', '1'); + entry('assistant', 'FINAL-ANSWER', '1'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event); + + expect(stripSgr(renderTranscript(driver))).toContain('FINAL-ANSWER'); + + const cronOrigin = { + kind: 'cron_job', + jobId: 'job-42', + cron: '*/5 * * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }; + emit({ type: 'turn.started', agentId: 'main', turnId: 2, origin: cronOrigin } as Event); + emit({ type: 'cron.fired', agentId: 'main', origin: cronOrigin, prompt: 'inspect the fleet' } as Event); + entry('assistant', 'cron report part one', '2'); + entry('assistant', 'cron report final', '2'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 2, reason: 'completed' } as Event); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('cron report final'); + expect(transcript).toContain('FINAL-ANSWER'); + }); + + it('keeps the in-flight answer mounted when a cron fires mid-turn', async () => { + const { driver } = await makeDriver(); + const emit = (event: Event) => driver.sessionEventHandler.handleEvent(event, () => {}); + let entrySeq = 0; + const entry = (kind: 'user' | 'assistant', content: string, turnId?: string) => { + entrySeq += 1; + driver.appendTranscriptEntry({ + id: `cron-buffered-${entrySeq}`, + kind, + turnId, + renderMode: kind === 'assistant' ? 'markdown' : 'plain', + content, + }); + }; + + entry('user', 'what is the answer?'); + emit({ type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event); + entry('assistant', 'FINAL-ANSWER', '1'); + + const cronOrigin = { + kind: 'cron_job', + jobId: 'job-42', + cron: '*/5 * * * *', + recurring: true, + coalescedCount: 1, + stale: false, + }; + emit({ type: 'cron.fired', agentId: 'main', origin: cronOrigin, prompt: 'inspect the fleet' } as Event); + entry('assistant', 'cron report part one', '1'); + entry('assistant', 'cron report final', '1'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('FINAL-ANSWER'); + expect(transcript).toContain('cron report final'); + }); + + it('keeps the previous turn’s final answer mounted when a task-notification turn completes', async () => { + const { driver } = await makeDriver(); + const emit = (event: Event) => driver.sessionEventHandler.handleEvent(event, () => {}); + let entrySeq = 0; + const entry = (kind: 'user' | 'assistant', content: string, turnId?: string) => { + entrySeq += 1; + driver.appendTranscriptEntry({ + id: `task-fold-${entrySeq}`, + kind, + turnId, + renderMode: kind === 'assistant' ? 'markdown' : 'plain', + content, + }); + }; + + entry('user', 'what is the answer?'); + emit({ type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event); + entry('assistant', 'working on it', '1'); + entry('assistant', 'FINAL-ANSWER', '1'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event); + + expect(stripSgr(renderTranscript(driver))).toContain('FINAL-ANSWER'); + + emit({ + type: 'background.task.terminated', + agentId: 'main', + info: { + taskId: 'task-1', + kind: 'process', + description: 'nightly sync', + status: 'completed', + exitCode: 0, + startedAt: 0, + endedAt: 1, + }, + } as unknown as Event); + const taskOrigin = { + kind: 'task', + taskId: 'task-1', + status: 'completed', + notificationId: 'ntf-1', + }; + emit({ type: 'turn.started', agentId: 'main', turnId: 2, origin: taskOrigin } as Event); + entry('assistant', 'task report part one', '2'); + entry('assistant', 'task report final', '2'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 2, reason: 'completed' } as Event); - const transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); - expect(transcript).toContain('Scheduled reminder fired'); - expect(transcript).toContain('* * * * *'); - expect(transcript).toContain('Remind the user: this is a once-per-minute reminder'); - expect(transcript).not.toContain(' { + it('keeps the previous turn’s final answer mounted when a background-agent notification turn completes', async () => { const { driver } = await makeDriver(); const emit = (event: Event) => driver.sessionEventHandler.handleEvent(event, () => {}); let entrySeq = 0; const entry = (kind: 'user' | 'assistant', content: string, turnId?: string) => { entrySeq += 1; driver.appendTranscriptEntry({ - id: `cron-fold-${entrySeq}`, + id: `agent-task-fold-${entrySeq}`, kind, turnId, renderMode: kind === 'assistant' ? 'markdown' : 'plain', @@ -4951,60 +6484,199 @@ command = "vim" expect(stripSgr(renderTranscript(driver))).toContain('FINAL-ANSWER'); - const cronOrigin = { - kind: 'cron_job', - jobId: 'job-42', - cron: '*/5 * * * *', - recurring: true, - coalescedCount: 1, - stale: false, + emit({ + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-9', + subagentName: 'explore', + parentToolCallId: 'tc-9', + description: 'scout the fleet', + runInBackground: true, + } as unknown as Event); + emit({ + type: 'background.task.started', + agentId: 'main', + info: { + taskId: 'task-9', + kind: 'agent', + agentId: 'agent-9', + description: 'scout the fleet', + status: 'running', + startedAt: 0, + }, + } as unknown as Event); + emit({ + type: 'subagent.completed', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-9', + parentToolCallId: 'tc-9', + resultSummary: 'fleet scouted', + } as unknown as Event); + emit({ + type: 'background.task.terminated', + agentId: 'main', + info: { + taskId: 'task-9', + kind: 'agent', + agentId: 'agent-9', + description: 'scout the fleet', + status: 'completed', + startedAt: 0, + endedAt: 1, + }, + } as unknown as Event); + + const taskOrigin = { + kind: 'task', + taskId: 'task-9', + status: 'completed', + notificationId: 'ntf-9', }; - emit({ type: 'turn.started', agentId: 'main', turnId: 2, origin: cronOrigin } as Event); - emit({ type: 'cron.fired', agentId: 'main', origin: cronOrigin, prompt: 'inspect the fleet' } as Event); - entry('assistant', 'cron report part one', '2'); - entry('assistant', 'cron report final', '2'); + emit({ type: 'turn.started', agentId: 'main', turnId: 2, origin: taskOrigin } as Event); + entry('assistant', 'agent report part one', '2'); + entry('assistant', 'agent report final', '2'); emit({ type: 'turn.ended', agentId: 'main', turnId: 2, reason: 'completed' } as Event); const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('cron report final'); + expect(transcript).toContain('agent report final'); expect(transcript).toContain('FINAL-ANSWER'); + const terminalCards = driver.state.transcriptEntries.filter( + (transcriptEntry) => transcriptEntry.backgroundAgentStatus?.phase === 'completed', + ); + expect(terminalCards).toHaveLength(1); }); - it('keeps the in-flight answer mounted when a cron fires mid-turn', async () => { + it('anchors the fold boundary to the notification turn when another task terminates mid-turn', async () => { const { driver } = await makeDriver(); const emit = (event: Event) => driver.sessionEventHandler.handleEvent(event, () => {}); let entrySeq = 0; const entry = (kind: 'user' | 'assistant', content: string, turnId?: string) => { entrySeq += 1; driver.appendTranscriptEntry({ - id: `cron-buffered-${entrySeq}`, + id: `task-anchor-${entrySeq}`, kind, turnId, renderMode: kind === 'assistant' ? 'markdown' : 'plain', content, }); }; + const terminate = (taskId: string) => + emit({ + type: 'background.task.terminated', + agentId: 'main', + info: { + taskId, + kind: 'process', + description: `task ${taskId}`, + status: 'completed', + exitCode: 0, + startedAt: 0, + endedAt: 1, + }, + } as unknown as Event); + const taskOrigin = (taskId: string) => ({ + kind: 'task', + taskId, + status: 'completed', + notificationId: `ntf-${taskId}`, + }); - entry('user', 'what is the answer?'); + entry('user', 'first question'); emit({ type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event); - entry('assistant', 'FINAL-ANSWER', '1'); + entry('assistant', 'working', '1'); + entry('assistant', 'USER-FINAL', '1'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event); - const cronOrigin = { - kind: 'cron_job', - jobId: 'job-42', - cron: '*/5 * * * *', - recurring: true, - coalescedCount: 1, - stale: false, + // task 1 terminates while idle; its notification turn opens and starts + // answering, then task 2 terminates mid-turn and its terminal card lands + // inside turn 2's output. + terminate('task-1'); + emit({ type: 'turn.started', agentId: 'main', turnId: 2, origin: taskOrigin('task-1') } as Event); + entry('assistant', 'report one intro', '2'); + terminate('task-2'); + entry('assistant', 'REPORT-ONE-FINAL', '2'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 2, reason: 'completed' } as Event); + + // task 2's notification missed turn 2's last drain, so it opens its own + // turn. When that turn ends, the fold must start at this turn's boundary — + // not at task 2's card mounted mid-turn-2 — or REPORT-ONE-FINAL folds away. + emit({ type: 'turn.started', agentId: 'main', turnId: 3, origin: taskOrigin('task-2') } as Event); + entry('assistant', 'report two intro', '3'); + entry('assistant', 'report two middle', '3'); + entry('assistant', 'REPORT-TWO-FINAL', '3'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 3, reason: 'completed' } as Event); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('REPORT-ONE-FINAL'); + expect(transcript).toContain('REPORT-TWO-FINAL'); + }); + + it('anchors the fold boundary by mounting a card when an abnormally terminated agent left none', async () => { + const { driver } = await makeDriver(); + const emit = (event: Event) => driver.sessionEventHandler.handleEvent(event, () => {}); + let entrySeq = 0; + const entry = (kind: 'user' | 'assistant', content: string, turnId?: string) => { + entrySeq += 1; + driver.appendTranscriptEntry({ + id: `agent-lost-fold-${entrySeq}`, + kind, + turnId, + renderMode: kind === 'assistant' ? 'markdown' : 'plain', + content, + }); }; - emit({ type: 'cron.fired', agentId: 'main', origin: cronOrigin, prompt: 'inspect the fleet' } as Event); - entry('assistant', 'cron report part one', '1'); - entry('assistant', 'cron report final', '1'); + + entry('user', 'what is the answer?'); + emit({ type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event); + entry('assistant', 'working on it', '1'); + entry('assistant', 'FINAL-ANSWER', '1'); emit({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event); + expect(stripSgr(renderTranscript(driver))).toContain('FINAL-ANSWER'); + + // The agent timed out without any subagent lifecycle event, so no + // terminal card was ever mounted for it — agent-kind task terminations + // mount none of their own. + emit({ + type: 'background.task.terminated', + agentId: 'main', + info: { + taskId: 'task-lost', + kind: 'agent', + agentId: 'agent-lost', + description: 'scout the fleet', + status: 'timed_out', + startedAt: 0, + endedAt: 1, + }, + } as unknown as Event); + expect( + driver.state.transcriptEntries.filter( + (transcriptEntry) => transcriptEntry.backgroundAgentStatus !== undefined, + ), + ).toHaveLength(0); + + // The notification turn anchors its fold boundary by mounting the missing + // terminal card at the turn's start. + emit({ + type: 'turn.started', + agentId: 'main', + turnId: 2, + origin: { kind: 'task', taskId: 'task-lost', status: 'timed_out', notificationId: 'ntf-lost' }, + } as Event); + entry('assistant', 'timeout report final', '2'); + emit({ type: 'turn.ended', agentId: 'main', turnId: 2, reason: 'completed' } as Event); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('timeout report final'); expect(transcript).toContain('FINAL-ANSWER'); - expect(transcript).toContain('cron report final'); + const terminalCards = driver.state.transcriptEntries.filter( + (transcriptEntry) => transcriptEntry.backgroundAgentStatus !== undefined, + ); + expect(terminalCards).toHaveLength(1); + expect(terminalCards[0]!.backgroundAgentStatus?.phase).toBe('failed'); }); it('coalesces assistant delta component updates', async () => { @@ -5412,6 +7084,151 @@ command = "vim" expect(session.prompt).not.toHaveBeenCalled(); }); + it.each([ + { prefix: '/skill:review inspect', skills: [{ name: 'review', args: expect.stringContaining('Attached image file:') }] }, + { prefix: 'inspect /skill:review', skills: [{ name: 'review' }] }, + { prefix: '/skill:review /skill:security', skills: [{ name: 'review' }, { name: 'security' }] }, + ])('sends a skill image to the open BTW panel without releasing it with the main turn ($prefix)', async ({ prefix, skills }) => { + const agentScopes: string[] = []; + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + { name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' }, + ]), + promptWithSkills: vi.fn(async () => { agentScopes.push(harness.interactiveAgentId); }), + }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + await openBtwPanel(driver, session); + getMountedBtwPanel(driver).markDone(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-btw-image'); + const input = `${prefix} ${attachment.placeholder}`; + driver.state.editor.setText(input); + driver.state.editor.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith([ + { type: 'text', text: `${prefix} ` }, + { type: 'image_url', imageUrl: { url: 'pythinker-file://file-btw-image' } }, + ], skills); + }); + expect(agentScopes).toEqual(['agent-btw']); + expect(stripSgr(renderBtwPanel(driver))).toContain(input); + emitTurn(driver, 1); + expect(harness.deleteFile).not.toHaveBeenCalled(); + driver.sessionEventHandler.handleEvent({ + type: 'turn.started', sessionId: session.id, agentId: 'agent-btw', + turnId: 1, origin: { kind: 'user' }, + } as Event, () => {}); + driver.sessionEventHandler.handleEvent({ + type: 'turn.ended', sessionId: session.id, agentId: 'agent-btw', + turnId: 1, reason: 'completed', + } as Event, () => {}); + await vi.waitFor(() => { + expect(harness.deleteFile).toHaveBeenCalledExactlyOnceWith('file-btw-image'); + }); + }); + + it.each(['ready', 'timeout', 'closed'] as const)('keeps a pending BTW skill video with its intended recipient (%s)', async (outcome) => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const sourcePath = join(process.env['PYTHINKER_CODE_HOME'], 'example.mp4'); + await writeFile(sourcePath, 'example video'); + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + await openBtwPanel(driver, session); + getMountedBtwPanel(driver).markDone(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addVideo('video/mp4', sourcePath); + let finishIngestion!: () => void; + attachment.pending = new Promise((resolve) => { + finishIngestion = () => { + imageStore.completeVideo(attachment, { fileId: 'file-btw-video' }); + resolve(); + }; + }); + const input = `/skill:review inspect ${attachment.placeholder}`; + vi.useFakeTimers(); + try { + driver.state.editor.setText(input); + driver.state.editor.handleInput('\r'); + expect(session.promptWithSkills).not.toHaveBeenCalled(); + if (outcome === 'closed') driver.btwPanelController.closeOrCancel(); + if (outcome === 'timeout') { + driver.state.editor.setText('keep this draft'); + await vi.advanceTimersByTimeAsync(2_000); + expect(stripSgr(renderBtwPanel(driver))).toContain('still uploading'); + expect(driver.state.editor.getText()).toBe('keep this draft'); + } + finishIngestion(); + await vi.advanceTimersByTimeAsync(0); + if (outcome === 'ready') { + expect(session.promptWithSkills).toHaveBeenCalledExactlyOnceWith([ + { type: 'text', text: '/skill:review inspect ' }, + { type: 'video_url', videoUrl: { url: 'pythinker-file://file-btw-video' } }, + ], [{ name: 'review', args: expect.stringContaining('Attached video file:') }]); + driver.sessionEventHandler.handleEvent({ + type: 'turn.started', sessionId: session.id, agentId: 'agent-btw', + turnId: 1, origin: { kind: 'user' }, + } as Event, () => {}); + driver.btwPanelController.closeOrCancel(); + driver.sessionEventHandler.handleEvent({ + type: 'turn.ended', sessionId: session.id, agentId: 'agent-btw', + turnId: 1, reason: 'cancelled', + } as Event, () => {}); + await vi.advanceTimersByTimeAsync(0); + expect(harness.deleteFile).toHaveBeenCalledExactlyOnceWith('file-btw-video'); + } else { + expect(session.promptWithSkills).not.toHaveBeenCalled(); + expect(session.prompt).toHaveBeenCalledTimes(1); + expect(imageStore.get(attachment.id)?.fileId).toBe('file-btw-video'); + } + } finally { + finishIngestion(); + vi.useRealTimers(); + } + }); + + it('reports a BTW skill failure without refilling the editor', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + promptWithSkills: vi.fn() + .mockRejectedValueOnce(new Error('Prompt rejected')) + .mockResolvedValue(undefined), + }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + await openBtwPanel(driver, session); + getMountedBtwPanel(driver).markDone(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-btw-retry'); + const input = `/skill:review inspect ${attachment.placeholder}`; + driver.state.editor.setText(input); + driver.state.editor.handleInput('\r'); + await vi.waitFor(() => { + expect(stripSgr(renderBtwPanel(driver))).toContain('Prompt rejected'); + expect(driver.state.editor.getText()).toBe(''); + }); + await vi.waitFor(() => { + expect(harness.deleteFile).toHaveBeenCalledExactlyOnceWith('file-btw-retry'); + }); + expect(driver.state.queuedMessages).toEqual([]); + expect(session.promptWithSkills).toHaveBeenCalledOnce(); + }); + it('activates inline skills in the initial /btw prompt (v2 engine)', async () => { const session = makeSession({ id: 'ses-lazy', @@ -5447,6 +7264,44 @@ command = "vim" expect(session.prompt).not.toHaveBeenCalled(); }); + it('cancels a BTW skill prompt still waiting for media ingestion', async () => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const sourcePath = join(process.env['PYTHINKER_CODE_HOME'], 'example.mp4'); + await writeFile(sourcePath, 'example video'); + const session = makeSession({ + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const { driver } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise } + ).refreshSkillCommands(session); + await openBtwPanel(driver, session); + getMountedBtwPanel(driver).markDone(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addVideo('video/mp4', sourcePath); + let finishIngestion!: () => void; + attachment.pending = new Promise((resolve) => { + finishIngestion = () => { + imageStore.completeVideo(attachment, { fileId: 'file-btw-cancel' }); + resolve(); + }; + }); + driver.state.editor.setText(`/skill:review inspect ${attachment.placeholder}`); + driver.state.editor.handleInput('\r'); + expect(getMountedBtwPanel(driver).isRunning()).toBe(true); + + expect(driver.btwPanelController.cancelRunning()).toBe(true); + finishIngestion(); + + await vi.waitFor(() => { + expect(getMountedBtwPanel(driver).isRunning()).toBe(false); + }); + expect(session.promptWithSkills).not.toHaveBeenCalled(); + expect(stripSgr(renderBtwPanel(driver))).toContain('Interrupted by user'); + }); + it('activates a leading skill token in the initial /btw prompt (v2 engine)', async () => { const session = makeSession({ id: 'ses-lazy', @@ -6232,6 +8087,62 @@ command = "vim" expect(driver.state.appState.towerMode).toBe(false); }); + it('renders a marker when tower mode exits because a goal was activated', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + towerMode: true, + } as Event, + vi.fn(), + ); + expect(driver.state.appState.towerMode).toBe(true); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + towerMode: false, + towerExitReason: 'mode-mutex', + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.towerMode).toBe(false); + const transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'Tower mode ended — a goal was activated')).toBe(1); + }); + + it('does not render the goal-exit marker for a reasonless tower exit', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + towerMode: true, + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + towerMode: false, + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.towerMode).toBe(false); + expect(stripSgr(renderTranscript(driver))).not.toContain('Tower mode ended'); + }); + it('renders dynamic_workflow mode markers from /dynamic_workflow commands, not tool-triggered status updates', async () => { const { driver } = await makeDriver(); @@ -8924,6 +10835,7 @@ command = "vim" expect(transcript).toContain('(empty)'); expect(transcript).not.toContain(' { @@ -9342,6 +11254,94 @@ describe('footer ctrl+o hint', () => { }); describe('PythinkerTUI session rating survey', () => { + it.each(['startup', 'switch', 'reload'] as const)( + 'reports cumulative user turns after a %s mount', + async (mount) => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + vi.spyOn(Math, 'random').mockReturnValue(0); + try { + const session = makeSession(); + const resumeState = session.getResumeState(); + resumeState.agents.main.userTurnCount = 40; + session.getResumeState.mockReturnValue(resumeState); + const startup = makeStartupInput(); + startup.cliOptions.session = 'ses-1'; + const { driver, harness } = await makeDriver( + session, + { listSessions: vi.fn(async () => [{ id: 'ses-1', workDir: '/tmp/proj-a' }]) }, + mount === 'startup' ? startup : undefined, + ); + if (mount === 'switch') { + emitTurn(driver, 1); + await driver.switchToSession(session as unknown as Session, 'Resumed session.'); + } else if (mount === 'reload') { + emitTurn(driver, 1); + await driver.reloadCurrentSessionView(session as unknown as Session, 'Reloaded session.'); + } + await readSurveyLastShownTime(); + vi.useFakeTimers(); + harness.trackWithContext.mockClear(); + emitTurn(driver, 41, () => { + driver.sessionEventHandler.handleEvent( + { type: 'agent.status.updated', agentId: 'main', contextTokens: 205_000 } as Event, + () => {}, + ); + }); + await vi.advanceTimersByTimeAsync(2_000); + + expect(harness.trackWithContext).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared', user_turn_count: 41 }), + { sessionId: 'ses-1' }, + ); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } + }, + ); + + it.each(['lazy', 'new-command'] as const)( + 'starts from zero when a new session is created through %s', + async (creation) => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + vi.spyOn(Math, 'random').mockReturnValue(0); + try { + const session = makeSession({ getResumeState: vi.fn(() => undefined) }); + const startup = makeStartupInput(); + startup.cliOptions.model = 'k2'; + const { driver, harness } = await makeDriver(session, {}, startup); + if (creation === 'lazy') { + driver.handleUserInput('first question'); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalled(); + }); + } else { + await driver.createNewSession(); + } + await readSurveyLastShownTime(); + vi.useFakeTimers(); + harness.trackWithContext.mockClear(); + emitTurn(driver, 1, () => { + driver.sessionEventHandler.handleEvent( + { type: 'agent.status.updated', agentId: 'main', contextTokens: 205_000 } as Event, + () => {}, + ); + }); + await vi.advanceTimersByTimeAsync(2_000); + + expect(harness.trackWithContext).toHaveBeenCalledWith( + 'long_context_survey', + expect.objectContaining({ event_type: 'appeared', user_turn_count: 1 }), + { sessionId: 'ses-1' }, + ); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } + }, + ); + it('runs the end-to-end rating flow after five user turns', async () => { vi.useFakeTimers(); const homeDir = await makeTempHome(); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index b4ea7d8ad..5e9fa2edb 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -4,15 +4,19 @@ import { join } from 'node:path'; import { log, type GoalSnapshot } from '@pymodel/pythinker-code-sdk'; import type { MigrationPlan } from '@pymodel/migration-legacy'; +import type { Component, Focusable } from '@pymodel/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { BannerProvider } from '#/tui/banner/banner-provider'; import { readBannerDisplayState } from '#/tui/banner/state'; import { handleLoginCommand, handleLogoutCommand } from '#/tui/commands/auth'; import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/commands/prompts'; +import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; import { BannerComponent } from '#/tui/components/chrome/banner'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { PythinkerTUI, type PythinkerTUIStartupInput, type TUIState } from '#/tui/pythinker-tui'; +import { maybeShowModelMigrationPrompt } from '#/tui/controllers/model-migration'; +import type { ApprovalPanelData, QuestionPanelData } from '#/tui/reverse-rpc/types'; import { REPLAY_FETCH_TURN_LIMIT } from '#/tui/utils/message-replay'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { quoteShellArg } from '#/utils/shell-quote'; @@ -31,6 +35,13 @@ vi.mock('#/tui/commands/prompts', async (importOriginal) => { vi.mock('#/utils/clipboard/clipboard-text', () => ({ copyTextToClipboard: vi.fn(async () => {}), })); +const modelMigrationState = vi.hoisted(() => ({ acknowledged: {} as Record })); +vi.mock('#/utils/model-migration-state', () => ({ + readModelMigrationAcknowledgements: vi.fn(async () => ({ ...modelMigrationState.acknowledged })), + acknowledgeModelMigration: vi.fn(async (fromAlias: string, toModel: string) => { + modelMigrationState.acknowledged = { ...modelMigrationState.acknowledged, [fromAlias]: toModel }; + }), +})); const copyTextToClipboardMock = vi.mocked(copyTextToClipboard); @@ -211,7 +222,7 @@ function makeHarness(session = makeSession(), overrides: Record setTelemetryContext: vi.fn(), getExperimentalFeatures: vi.fn(async () => []), getWorkspaceTrustInfo: vi.fn(async () => ({ trusted: true, gatedMcpServers: [], gatedAdditionalDirs: [], additionalDirSources: [], warnings: [], instructionSources: { agentsMdPaths: [], skills: [], agentProfiles: [], paths: [] } })), - supportsAtomicSectionReplace: vi.fn(() => false), + refreshProviderModels: vi.fn(async () => ({ changed: [], unchanged: [], failed: [] })), auth: { status: vi.fn(async () => ({ providers: [] })), login: vi.fn(async () => {}), @@ -1881,9 +1892,9 @@ describe('PythinkerTUI startup', () => { const showStatus = vi.spyOn(driver as any, "showStatus").mockImplementation(() => {}); vi.spyOn((driver as any).authFlow, "refreshProviderModels").mockResolvedValue({ changed: [ - { providerId: "new-models", providerName: "New Models", added: 2, removed: 0 }, - { providerId: "removed-models", providerName: "Removed Models", added: 0, removed: 3 }, - { providerId: "metadata-only", providerName: "Metadata Only", added: 0, removed: 0 }, + { provider_id: "new-models", provider_name: "New Models", added: 2, removed: 0 }, + { provider_id: "removed-models", provider_name: "Removed Models", added: 0, removed: 3 }, + { provider_id: "metadata-only", provider_name: "Metadata Only", added: 0, removed: 0 }, ], unchanged: [], failed: [], @@ -1895,116 +1906,643 @@ describe('PythinkerTUI startup', () => { expect(showStatus).toHaveBeenCalledWith("New Models · +2 models."); }); - it("stages provider-refresh removals and persists one atomic write on atomic-capable harnesses", async () => { - const registryUrl = "https://registry.example.test/v1/models/api.json"; - const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" }; - const replaceConfigSections = vi.fn(async (_sections: Record) => {}); - const removeProvider = vi.fn(async () => ({})); + async function flushMigrationCheck(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + } + + function installMigrationModels(driver: StartupDriver): void { + modelMigrationState.acknowledged = {}; + driver.state.appState.model = 'acme/current'; + driver.state.appState.thinkingEffort = 'off'; + driver.state.appState.availableProviders = { + acme: { type: 'openai' }, + }; + driver.state.appState.availableModels = { + 'acme/current': { + provider: 'acme', + model: 'current', + maxContextSize: 100, + capabilities: ['tool_use'], + upgrade: { + model: 'acme/next', + migrationMarkdown: 'Use current to next', + retirementAt: undefined, + }, + }, + 'acme/next': { + provider: 'acme', + model: 'next', + maxContextSize: 100, + capabilities: ['tool_use', 'thinking'], + supportEfforts: ['low', 'high'], + defaultEffort: 'high', + }, + }; + } + + it.each(['off', 'high'])('persists the migration default effort when the live effort is %s', async (liveEffort) => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'acme/next', + thinkingEffort: 'high', + permission: 'manual', + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + }); const setConfig = vi.fn(async () => ({})); - const harness = makeHarness(makeSession(), { - supportsAtomicSectionReplace: vi.fn(() => true), - replaceConfigSections, - removeProvider, + const harness = makeHarness(session, { setConfig, getConfig: vi.fn(async () => ({ - providers: { - a: { type: "openai", baseUrl: "https://a.example.test/v1", apiKey: "sk-test-token", source }, - b: { type: "openai", baseUrl: "https://b.example.test/v1", apiKey: "sk-test-token", source }, - }, - models: { - "a/m1": { provider: "a", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, - "b/m1": { provider: "b", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, - }, - defaultModel: "b/m1", - thinking: { enabled: true }, + defaultModel: 'acme/current', + thinking: { enabled: true, effort: 'low' }, })), }); const driver = makeDriver(harness, makeStartupInput()); - vi.stubGlobal( - "fetch", - vi.fn(async () => - new Response( - JSON.stringify({ - a: { - id: "a", - name: "Provider A", - api: "https://a.example.test/v1", - type: "openai", - models: { m1: { id: "m1" } }, - }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ), - ); - try { - const result = await (driver as any).authFlow.refreshProviderModels(); + await driver.setSession(session); + installMigrationModels(driver); + driver.state.appState.thinkingEffort = liveEffort; - expect(result.failed).toEqual([]); - expect(result.changed).toContainEqual({ providerId: "b", providerName: "b", added: 0, removed: 1 }); - expect(removeProvider).not.toHaveBeenCalled(); - expect(setConfig).not.toHaveBeenCalled(); - expect(replaceConfigSections).toHaveBeenCalledTimes(1); - const sections = replaceConfigSections.mock.calls[0]?.[0] as Record; - expect(Object.keys(sections["providers"] as object)).toEqual(["a"]); - expect(sections["models"]).not.toHaveProperty("b/m1"); - expect(sections["defaultModel"]).toBeUndefined(); - expect(sections["thinking"]).toBeUndefined(); - } finally { - vi.unstubAllGlobals(); + const prompt = maybeShowModelMigrationPrompt(driver as any, {}); + const picker = driver.state.editorContainer.children[0] as { + handleInput(data: string): void; + render(width: number): string[]; + }; + expect(picker.render(100).join('\n')).toContain('Use current to next'); + picker.handleInput('\r'); + await prompt; + + expect(session.setModel).toHaveBeenCalledWith('acme/next'); + if (liveEffort === 'high') { + expect(session.setThinking).not.toHaveBeenCalled(); + } else { + expect(session.setThinking).toHaveBeenCalledWith('high'); } + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'acme/next', + thinking: { enabled: true, effort: 'high' }, + }); + expect(modelMigrationState.acknowledged).toEqual({ 'acme/current': 'next' }); }); - it("keeps the two-phase removeProvider/setConfig host on harnesses without atomic replace", async () => { - const registryUrl = "https://registry.example.test/v1/models/api.json"; - const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" }; - const replaceConfigSections = vi.fn(async () => {}); - const removeProvider = vi.fn(async () => ({})); - const setConfig = vi.fn(async (patch: Record) => patch); - const harness = makeHarness(makeSession(), { - replaceConfigSections, - removeProvider, + it.each([ + ['low', ['low', 'high'], 'low'], + ['off', ['low', 'high'], 'off'], + ['max', ['max'], 'high'], + ] as const)('resolves a live %s effort on a thinking source to the migration effort', async (liveEffort, sourceEfforts, expected) => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'acme/next', + thinkingEffort: expected, + permission: 'manual', + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + }); + const setConfig = vi.fn(async () => ({})); + const harness = makeHarness(session, { setConfig, - getConfig: vi.fn(async () => ({ - providers: { - a: { type: "openai", baseUrl: "https://a.example.test/v1", apiKey: "sk-test-token", source }, - b: { type: "openai", baseUrl: "https://b.example.test/v1", apiKey: "sk-test-token", source }, - }, - models: { - "a/m1": { provider: "a", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, - "b/m1": { provider: "b", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, - }, - defaultModel: "b/m1", + getConfig: vi.fn(async () => ({ defaultModel: 'acme/current', thinking: { enabled: true } })), + }); + const driver = makeDriver(harness, makeStartupInput()); + await driver.setSession(session); + installMigrationModels(driver); + Object.assign(driver.state.appState.availableModels['acme/current']!, { + capabilities: ['tool_use', 'thinking'], + supportEfforts: [...sourceEfforts], + }); + driver.state.appState.thinkingEffort = liveEffort; + + const prompt = maybeShowModelMigrationPrompt(driver as any, {}); + const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; + expect(picker.render(100).join('\n')).toContain(`thinking ${expected}`); + picker.handleInput('\r'); + await prompt; + + expect(session.setModel).toHaveBeenCalledWith('acme/next'); + if (expected === liveEffort) { + expect(session.setThinking).not.toHaveBeenCalled(); + } else { + expect(session.setThinking).toHaveBeenCalledWith(expected); + } + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'acme/next', + thinking: expected === 'off' ? { enabled: false } : { enabled: true, effort: expected }, + }); + }); + + it.each(['draft', 'streaming', 'dialog', 'compaction', 'replay', 'external-editor', 'shutdown'])('defers the startup migration during %s', async (blockedBy) => { + const session = makeSession(); + const setConfig = vi.fn(); + const driver = makeDriver(makeHarness(session, { setConfig }), makeStartupInput()); + installMigrationModels(driver); + const startup = driver as unknown as { + backgroundRefreshPromise: Promise; + finishStartup(replay: boolean): Promise; + setAppState(patch: Record): void; + restoreEditor(): void; + setExternalEditorRunning(running: boolean): void; + aborted: boolean; + }; + let completeRefresh!: () => void; + startup.backgroundRefreshPromise = new Promise((resolve) => { completeRefresh = resolve; }); + await startup.finishStartup(false); + if (blockedBy === 'draft') driver.state.editor.setText('Please help'); + if (blockedBy === 'streaming') startup.setAppState({ streamingPhase: 'waiting' }); + if (blockedBy === 'dialog') driver.state.editorReplacementMounted = true; + if (blockedBy === 'compaction') startup.setAppState({ isCompacting: true }); + if (blockedBy === 'replay') startup.setAppState({ isReplaying: true }); + if (blockedBy === 'external-editor') startup.setExternalEditorRunning(true); + if (blockedBy === 'shutdown') startup.aborted = true; + + completeRefresh(); + await flushMigrationCheck(); + expect(driver.state.editorContainer.children[0]).not.toBeInstanceOf(ChoicePickerComponent); + if (blockedBy === 'draft') { + driver.state.editor.handleInput(' '); + expect(driver.state.editor.getText()).toBe('Please help '); + driver.state.editor.setText(''); + } + if (blockedBy === 'streaming') startup.setAppState({ streamingPhase: 'idle' }); + if (blockedBy === 'dialog') startup.restoreEditor(); + if (blockedBy === 'compaction') startup.setAppState({ isCompacting: false }); + if (blockedBy === 'replay') startup.setAppState({ isReplaying: false }); + if (blockedBy === 'external-editor') startup.setExternalEditorRunning(false); + await flushMigrationCheck(); + expect(session.setModel).not.toHaveBeenCalled(); + expect(setConfig).not.toHaveBeenCalled(); + if (blockedBy === 'shutdown') { + expect(driver.state.editorReplacementMounted).toBe(false); + return; + } + const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; + expect(picker).toBeInstanceOf(ChoicePickerComponent); + picker.handleInput(' '); + expect(driver.state.editorReplacementMounted).toBe(true); + expect(modelMigrationState.acknowledged).toEqual({}); + picker.handleInput('\u001B'); + await flushMigrationCheck(); + expect(driver.state.editorReplacementMounted).toBe(false); + }); + + it('does not mount a migration while a submitted draft is creating its first session', async () => { + const prompt = vi.fn(async () => {}); + const session = makeSession({ + prompt, + getStatus: vi.fn(async () => ({ + model: 'acme/current', thinkingEffort: 'off', permission: 'manual', planMode: false, + contextTokens: 10, maxContextTokens: 100, contextUsage: 0.1, })), }); + let completeCreation!: () => void; + const harness = makeHarness(session, { + createSession: vi.fn(async () => { + await new Promise((resolve) => { completeCreation = resolve; }); + return session; + }), + }); const driver = makeDriver(harness, makeStartupInput()); - vi.stubGlobal( - "fetch", - vi.fn(async () => - new Response( - JSON.stringify({ - a: { - id: "a", - name: "Provider A", - api: "https://a.example.test/v1", - type: "openai", - models: { m1: { id: "m1" } }, - }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ), - ); - try { - const result = await (driver as any).authFlow.refreshProviderModels(); + installMigrationModels(driver); + const startup = driver as unknown as { + backgroundRefreshPromise: Promise; + finishStartup(replay: boolean): Promise; + persistInputHistory(text: string): Promise; + setAppState(patch: Record): void; + }; + vi.spyOn(startup, 'persistInputHistory').mockResolvedValue(undefined); + driver.state.editor.setText('First prompt'); + startup.backgroundRefreshPromise = Promise.resolve(); + await startup.finishStartup(false); + await flushMigrationCheck(); + + driver.state.editor.handleInput('\r'); + await flushMigrationCheck(); + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(driver.state.editorReplacementMounted).toBe(false); + expect(prompt).not.toHaveBeenCalled(); + completeCreation(); + await flushMigrationCheck(); + expect(prompt).toHaveBeenCalled(); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.editorReplacementMounted).toBe(false); + + startup.setAppState({ streamingPhase: 'idle' }); + await flushMigrationCheck(); + const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; + expect(picker).toBeInstanceOf(ChoicePickerComponent); + picker.handleInput('\u001B'); + }); + + it('does not mount a migration while /new is replacing the session', async () => { + const getStatus = vi.fn(async () => ({ + model: 'acme/current', thinkingEffort: 'off', permission: 'manual', planMode: false, + contextTokens: 10, maxContextTokens: 100, contextUsage: 0.1, + })); + const oldSession = makeSession({ getStatus }); + const newSession = makeSession({ id: 'ses-2', getStatus }); + let completeCreation!: () => void; + const harness = makeHarness(newSession, { + createSession: vi.fn(async () => { + await new Promise((resolve) => { completeCreation = resolve; }); + return newSession; + }), + }); + const driver = makeDriver(harness, makeStartupInput()); + installMigrationModels(driver); + await driver.setSession(oldSession); + const startup = driver as unknown as { + modelMigration: { request(): void }; + createNewSession(): Promise; + }; - expect(result.failed).toEqual([]); - expect(removeProvider).toHaveBeenCalledWith("b"); - expect(setConfig).toHaveBeenCalledTimes(1); - expect(replaceConfigSections).not.toHaveBeenCalled(); - } finally { - vi.unstubAllGlobals(); + startup.modelMigration.request(); + const replacing = startup.createNewSession(); + await flushMigrationCheck(); + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(driver.state.editorReplacementMounted).toBe(false); + expect(newSession.setModel).not.toHaveBeenCalled(); + + completeCreation(); + await replacing; + await flushMigrationCheck(); + const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; + expect(picker).toBeInstanceOf(ChoicePickerComponent); + expect(picker.render(100).join('\n')).toContain('Model migration available'); + picker.handleInput('\u001B'); + }); + + it.each([true, false])('checks migrations after the startup session picker (refresh first: %s)', async (refreshFirst) => { + const session = makeSession({ getStatus: vi.fn(async () => ({ + model: 'acme/current', thinkingEffort: 'off', permission: 'manual', planMode: false, + contextTokens: 10, maxContextTokens: 100, contextUsage: 0.1, + })) }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ + id: session.id, title: 'Picked', workDir: '/tmp/proj-a', updatedAt: Date.now(), + }]), + }); + const driver = makeDriver(harness, makeStartupInput({ session: '' })); + installMigrationModels(driver); + harness.getConfig.mockResolvedValue({ models: driver.state.appState.availableModels } as never); + const startup = driver as unknown as { + authFlow: { refreshProviderModels(): Promise }; + sessionReplay: { hydrateFromReplay(session: unknown): Promise }; + initMainTui(): Promise; + finishStartup(replay: boolean): Promise; + }; + let completeRefresh!: () => void; + vi.spyOn(startup.authFlow, 'refreshProviderModels').mockImplementation(async () => { + await new Promise((resolve) => { completeRefresh = resolve; }); + return { changed: [], unchanged: [], failed: [] }; + }); + vi.spyOn(startup.sessionReplay, 'hydrateFromReplay').mockResolvedValue(undefined); + await startup.initMainTui(); + await startup.finishStartup(false); + await flushMigrationCheck(); + expect(driver.state.activeDialog).toBe('session-picker'); + if (refreshFirst) { + completeRefresh(); + await flushMigrationCheck(); + expect(driver.state.activeDialog).toBe('session-picker'); + } + const sessionPicker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + sessionPicker.handleInput('\r'); + await flushMigrationCheck(); + expect(harness.resumeSession).toHaveBeenCalled(); + if (!refreshFirst) { + expect(driver.state.editorReplacementMounted).toBe(false); + completeRefresh(); + await flushMigrationCheck(); + } + const migrationPicker = driver.state.editorContainer.children[0] as ChoicePickerComponent; + expect(migrationPicker).toBeInstanceOf(ChoicePickerComponent); + expect(migrationPicker.render(100).join('\n')).toContain('Model migration available'); + migrationPicker.handleInput('\u001B'); + }); + + it('keeps the current model and acknowledges the migration', async () => { + const session = makeSession(); + const driver = makeDriver(makeHarness(session), makeStartupInput()); + await driver.setSession(session); + installMigrationModels(driver); + + const prompt = maybeShowModelMigrationPrompt(driver as any, {}); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u001B[B'); + picker.handleInput('\r'); + await prompt; + + expect(session.setModel).not.toHaveBeenCalled(); + expect(session.setThinking).not.toHaveBeenCalled(); + expect(driver.state.appState.model).toBe('acme/current'); + expect(modelMigrationState.acknowledged).toEqual({ 'acme/current': 'next' }); + }); + + it('renders provider migration Markdown', async () => { + const driver = makeDriver(makeHarness(), makeStartupInput()); + installMigrationModels(driver); + driver.state.appState.availableModels['acme/current']!.upgrade!.migrationMarkdown = + '### Upgrade\n\nSwitch **current** to **next**.\n\n- Faster replies'; + + const prompt = maybeShowModelMigrationPrompt(driver as any, {}); + const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; + const output = picker.render(100).join('\n'); + expect(output).not.toContain('###'); + expect(output).not.toContain('**'); + expect(output).toContain('current'); + expect(output).toContain('next'); + picker.handleInput('\u001B'); + await prompt; + }); + + it('strips terminal control sequences from registry-provided migration text', async () => { + const driver = makeDriver(makeHarness(), makeStartupInput()); + installMigrationModels(driver); + driver.state.appState.availableModels['acme/current']!.upgrade!.migrationMarkdown = + 'Move to next\u001B]0;pwned\u0007 soon\u001B[2J'; + driver.state.appState.availableModels['acme/next']!.displayName = + 'Next\u001B]8;;https://example.test\u0007'; + + const prompt = maybeShowModelMigrationPrompt(driver as any, {}); + const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; + const output = picker.render(100).join('\n'); + expect(output).toContain('Move to next soon'); + expect(output).toContain('Switch to Next'); + expect(output).not.toContain('pwned'); + expect(output).not.toContain('\u001B[2J'); + expect(output).not.toContain('example.test'); + picker.handleInput('\u001B'); + await prompt; + }); + + it.each([true, false])('warns about losing the prompt cache only when the conversation has history: %s', async (withHistory) => { + const driver = makeDriver(makeHarness(), makeStartupInput()); + installMigrationModels(driver); + if (withHistory) { + driver.state.transcriptEntries.push({ id: 'u1', kind: 'user', renderMode: 'plain', content: 'hello' }); + } + + const prompt = maybeShowModelMigrationPrompt(driver as any, {}); + const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; + const output = picker.render(200).join('\n'); + if (withHistory) { + expect(output).toContain('Switching models invalidates the existing prompt cache'); + } else { + expect(output).not.toContain('prompt cache'); + } + picker.handleInput('\u001B'); + await prompt; + }); + + it.each(['replace', 'restore'])('settles a displaced migration on editor %s without saving', async (action) => { + const setConfig = vi.fn(); + const driver = makeDriver(makeHarness(makeSession(), { setConfig }), makeStartupInput()); + installMigrationModels(driver); + const host = driver as unknown as PythinkerTUI; + const onDisplaced = vi.fn(); + const settled = vi.fn(); + const prompt = maybeShowModelMigrationPrompt(host, {}, onDisplaced).then(settled); + const oldPicker = driver.state.editorContainer.children[0] as ChoicePickerComponent; + const replacement = new ChoicePickerComponent({ + title: 'Another dialog', options: [], onSelect: vi.fn(), onCancel: vi.fn(), + }); + + if (action === 'replace') host.mountEditorReplacement(replacement); + else host.restoreEditor(); + await flushMigrationCheck(); + expect(settled).toHaveBeenCalledOnce(); + await prompt; + expect(onDisplaced).toHaveBeenCalledOnce(); + oldPicker.handleInput('\r'); + oldPicker.handleInput('\u001B'); + expect(driver.state.editorContainer.children[0]).toBe(action === 'replace' ? replacement : driver.state.editor); + expect(setConfig).not.toHaveBeenCalled(); + expect(modelMigrationState.acknowledged).toEqual({}); + host.restoreEditor(); + expect(onDisplaced).toHaveBeenCalledOnce(); + }); + + it.each(['approval', 'question'])('requeues a migration displaced by a background %s', async (modal) => { + const session = makeSession(); + const setConfig = vi.fn(); + const driver = makeDriver(makeHarness(session, { setConfig }), makeStartupInput({}, { + notifications: { enabled: false, condition: 'unfocused' }, + })); + installMigrationModels(driver); + const host = driver as unknown as { + modelMigration: { request(): void }; + showApprovalPanel(data: ApprovalPanelData): void; + hideApprovalPanel(): void; + showQuestionDialog(data: QuestionPanelData): void; + hideQuestionDialog(): void; + setAppState(patch: Record): void; + restoreEditor(): void; + }; + host.modelMigration.request(); + await flushMigrationCheck(); + const oldPicker = driver.state.editorContainer.children[0] as ChoicePickerComponent; + expect(oldPicker).toBeInstanceOf(ChoicePickerComponent); + + if (modal === 'approval') { + host.showApprovalPanel({ + id: 'approval-1', tool_call_id: 'tool-1', tool_name: 'WriteFile', + action: 'Write file', description: '', display: [], + choices: [{ label: 'Approve once', response: 'approved' }], + }); + } else { + host.showQuestionDialog({ + id: 'question-1', tool_call_id: 'tool-1', + questions: [{ question: 'Choose a path', multi_select: false, options: [{ label: 'A' }] }], + }); + } + const panel = driver.state.editorContainer.children[0] as Component & Focusable; + await flushMigrationCheck(); + expect(panel).not.toBe(oldPicker); + expect(panel.focused).toBe(true); + oldPicker.handleInput('\r'); + oldPicker.handleInput('\u001B'); + expect(driver.state.editorContainer.children[0]).toBe(panel); + expect(panel.focused).toBe(true); + expect(session.setModel).not.toHaveBeenCalled(); + expect(setConfig).not.toHaveBeenCalled(); + expect(modelMigrationState.acknowledged).toEqual({}); + + host.setAppState({ streamingPhase: 'waiting' }); + if (modal === 'approval') host.hideApprovalPanel(); + else host.hideQuestionDialog(); + await flushMigrationCheck(); + expect(driver.state.editorReplacementMounted).toBe(false); + host.setAppState({ streamingPhase: 'idle' }); + await flushMigrationCheck(); + const retried = driver.state.editorContainer.children[0] as ChoicePickerComponent; + expect(retried).toBeInstanceOf(ChoicePickerComponent); + expect(retried).not.toBe(oldPicker); + retried.handleInput('\u001B'); + host.restoreEditor(); + await flushMigrationCheck(); + expect(driver.state.editorReplacementMounted).toBe(false); + expect(modelMigrationState.acknowledged).toEqual({}); + }); + + it('cancels a model migration without acknowledging it', async () => { + const session = makeSession(); + const driver = makeDriver(makeHarness(session), makeStartupInput()); + await driver.setSession(session); + installMigrationModels(driver); + + const prompt = maybeShowModelMigrationPrompt(driver as any, {}); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\u001B'); + await prompt; + + expect(session.setModel).not.toHaveBeenCalled(); + expect(driver.state.editorReplacementMounted).toBe(false); + expect(modelMigrationState.acknowledged).toEqual({}); + }); + + it('skips acknowledged migrations and targets outside the current provider', async () => { + const driver = makeDriver(makeHarness(), makeStartupInput()); + installMigrationModels(driver); + + await maybeShowModelMigrationPrompt(driver as any, { 'acme/current': 'next' }); + expect(driver.state.editorReplacementMounted).toBe(false); + + driver.state.appState.availableModels = { + 'acme/current': driver.state.appState.availableModels['acme/current']!, + 'other/next': { + provider: 'other', + model: 'next', + maxContextSize: 100, + }, + }; + await maybeShowModelMigrationPrompt(driver as any, {}); + expect(driver.state.editorReplacementMounted).toBe(false); + }); + + it('skips a startup migration acknowledged in the state file', async () => { + const driver = makeDriver(makeHarness(), makeStartupInput()); + installMigrationModels(driver); + modelMigrationState.acknowledged = { 'acme/current': 'next' }; + const startup = driver as unknown as { + backgroundRefreshPromise: Promise; + finishStartup(replay: boolean): Promise; + }; + startup.backgroundRefreshPromise = Promise.resolve(); + + await startup.finishStartup(false); + await flushMigrationCheck(); + + expect(driver.state.editorReplacementMounted).toBe(false); + }); + + describe('when a refresh finds the default model dropped by its registry', () => { + const retiredDefault = { from: 'acme/current', to: 'acme/next', from_display_name: 'Current' }; + + async function refreshDroppingDefault(options: { + readonly withSession: boolean; + readonly model: string; + readonly lazySessionThinking?: 'high'; + }) { + const session = makeSession(); + const setConfig = vi.fn(async () => ({})); + const harness = makeHarness(session, { + setConfig, + getConfig: vi.fn(async () => ({ + providers: { acme: { type: 'openai' } }, + models: { 'acme/next': { provider: 'acme', model: 'next', maxContextSize: 200 } }, + defaultModel: 'acme/next', + thinking: { enabled: true, effort: 'medium' }, + })), + }); + const driver = makeDriver(harness, makeStartupInput()); + if (options.withSession) await driver.setSession(session); + installMigrationModels(driver); + const remaining = { ...driver.state.appState.availableModels }; + delete remaining['acme/current']; + driver.state.appState.model = options.model; + driver.state.appState.thinkingEffort = options.lazySessionThinking ?? 'low'; + driver.state.appState.lazySessionThinking = options.lazySessionThinking; + const showStatus = vi.spyOn(driver as any, 'showStatus'); + const startup = driver as unknown as { + authFlow: { refreshProviderModels(): Promise }; + refreshProviderModelsInBackground(): Promise; + modelMigration: { retry(): void }; + }; + vi.spyOn(startup.authFlow, 'refreshProviderModels').mockImplementation(async () => { + driver.state.appState.availableModels = remaining; + return { changed: [], unchanged: [], failed: [], retired_default: retiredDefault }; + }); + driver.state.activeDialog = 'session-picker'; + driver.state.editor.setText('draft'); + + await startup.refreshProviderModelsInBackground(); + await flushMigrationCheck(); + expect(showStatus).not.toHaveBeenCalled(); + driver.state.activeDialog = null; + startup.modelMigration.retry(); + await flushMigrationCheck(); + + expect(showStatus).toHaveBeenCalledWith( + 'Current is no longer offered by its provider; the default model is now next.', + 'warning', + ); + return { driver, session, setConfig }; } + + it('warns once the session picker closes and leaves the live session to the engine', async () => { + const { session, setConfig } = await refreshDroppingDefault({ + withSession: true, + model: 'acme/current', + }); + + expect(session.setModel).not.toHaveBeenCalled(); + expect(session.setThinking).not.toHaveBeenCalled(); + expect(setConfig).not.toHaveBeenCalled(); + }); + + it('moves a session-less selection onto the new default with the configured thinking', async () => { + const { driver, setConfig } = await refreshDroppingDefault({ + withSession: false, + model: 'acme/current', + }); + + expect(driver.state.appState).toMatchObject({ + model: 'acme/next', + maxContextTokens: 200, + thinkingEffort: 'medium', + lazySessionThinking: undefined, + }); + expect(setConfig).not.toHaveBeenCalled(); + }); + + it('keeps a session-only effort picked before the first prompt', async () => { + const { driver } = await refreshDroppingDefault({ + withSession: false, + model: 'acme/current', + lazySessionThinking: 'high', + }); + + expect(driver.state.appState).toMatchObject({ + model: 'acme/next', + thinkingEffort: 'high', + lazySessionThinking: 'high', + }); + }); + + it('leaves a session-less selection on another model alone', async () => { + const { driver } = await refreshDroppingDefault({ withSession: false, model: 'acme/next' }); + + expect(driver.state.appState).toMatchObject({ + model: 'acme/next', + thinkingEffort: 'low', + lazySessionThinking: undefined, + }); + }); }); it('preserves fresh startup yolo and plan intent after OAuth login', async () => { diff --git a/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts b/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts deleted file mode 100644 index 91ba1c29e..000000000 --- a/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts +++ /dev/null @@ -1,549 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { refreshAllProviderModels } from '../../../src/tui/utils/refresh-providers'; -import type { PythinkerConfig } from '@pymodel/pythinker-code-sdk'; - -type FetchMock = ( - input: Parameters[0], - init?: Parameters[1], -) => Promise; - -function fetchInputUrl(input: Parameters[0]): string { - if (typeof input === 'string') return input; - if (input instanceof URL) return input.href; - return input.url; -} - -function makeRefreshHost(initial: PythinkerConfig): { - current: () => PythinkerConfig; - removeProvider: ReturnType Promise>>; - setConfig: ReturnType) => Promise>>; -} { - let persisted = structuredClone(initial); - const removeProvider = vi.fn(async (providerId: string) => { - const providers = { ...persisted.providers }; - delete providers[providerId]; - const models = { ...persisted.models }; - let defaultRemoved = false; - for (const [alias, model] of Object.entries(models)) { - if (model.provider !== providerId) continue; - delete models[alias]; - if (persisted.defaultModel === alias) defaultRemoved = true; - } - persisted = { ...persisted, providers, models }; - if (defaultRemoved) persisted = { ...persisted, defaultModel: undefined }; - return structuredClone(persisted); - }); - const setConfig = vi.fn(async (patch: Partial) => { - persisted = { ...persisted, ...patch }; - return structuredClone(persisted); - }); - return { - current: () => structuredClone(persisted), - removeProvider, - setConfig, - }; -} - -describe('refreshAllProviderModels', () => { - afterEach(() => { - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); - }); - - it('refreshes custom-registry model capabilities even when model ids are unchanged', async () => { - const registryUrl = 'https://registry.example.test/v1/models/api.json'; - const providerId = 'example_chat-completions'; - const siblingProviderId = 'example_messages'; - const modelId = 'reasoner-pro'; - const modelAlias = `${providerId}/${modelId}`; - const siblingModelAlias = `${siblingProviderId}/${modelId}`; - const userAlias = 'my-reasoner'; - const userAliasModel = { - provider: providerId, - model: modelId, - maxContextSize: 262144, - capabilities: ['tool_use'], - displayName: 'My Reasoner', - }; - const host = makeRefreshHost({ - providers: { - [providerId]: { - type: 'openai', - baseUrl: 'https://api.example.test/v1', - apiKey: 'sk-test-token', - source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, - }, - [siblingProviderId]: { - type: 'anthropic', - baseUrl: 'https://messages.example.test', - apiKey: 'sk-test-token', - source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, - }, - }, - models: { - [modelAlias]: { - provider: providerId, - model: modelId, - maxContextSize: 262144, - capabilities: ['tool_use'], - displayName: 'Reasoner Pro', - }, - [siblingModelAlias]: { - provider: siblingProviderId, - model: modelId, - maxContextSize: 262144, - capabilities: ['tool_use'], - displayName: 'Reasoner Pro', - }, - [userAlias]: userAliasModel, - }, - defaultModel: modelAlias, - telemetry: true, - } as unknown as PythinkerConfig); - - const fetchMock = vi.fn(async (input, init) => { - expect(fetchInputUrl(input)).toBe(registryUrl); - expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token'); - return new Response( - JSON.stringify({ - [providerId]: { - id: providerId, - name: 'Example Chat Completions', - api: 'https://api.example.test/v1', - type: 'openai', - models: { - [modelId]: { - id: modelId, - name: 'Reasoner Pro', - limit: { context: 262144, output: 262144 }, - tool_call: true, - reasoning: true, - modalities: { input: ['text', 'image', 'video'], output: ['text'] }, - }, - }, - }, - [siblingProviderId]: { - id: siblingProviderId, - name: 'Example Messages', - api: 'https://messages.example.test', - type: 'anthropic', - models: { - [modelId]: { - id: modelId, - name: 'Reasoner Pro', - limit: { context: 262144, output: 262144 }, - tool_call: true, - reasoning: true, - modalities: { input: ['text', 'image', 'video'], output: ['text'] }, - }, - }, - }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - }); - vi.stubGlobal('fetch', fetchMock); - - const result = await refreshAllProviderModels({ - getConfig: async () => host.current(), - removeProvider: host.removeProvider, - setConfig: host.setConfig, - }); - - expect(result.failed).toEqual([]); - expect(result.unchanged).toEqual([]); - expect(result.changed).toEqual([ - { - providerId, - providerName: 'Example Chat Completions', - added: 0, - removed: 0, - }, - { - providerId: siblingProviderId, - providerName: 'Example Messages', - added: 0, - removed: 0, - }, - ]); - expect(host.removeProvider).toHaveBeenCalledWith(providerId); - expect(host.removeProvider).toHaveBeenCalledWith(siblingProviderId); - expect(host.setConfig).toHaveBeenCalledTimes(1); - expect(host.current().models?.[modelAlias]?.capabilities).toEqual([ - 'tool_use', - 'thinking', - 'image_in', - 'video_in', - ]); - expect(host.current().models?.[siblingModelAlias]?.capabilities).toEqual([ - 'tool_use', - 'thinking', - 'image_in', - 'video_in', - ]); - expect(host.current().models?.[userAlias]).toEqual(userAliasModel); - }); - - it('adds custom-registry providers that appear under an existing source URL', async () => { - const registryUrl = 'https://registry.example.test/v1/models/api.json'; - const apiKey = 'sk-test-token'; - const source = { kind: 'apiJson', url: registryUrl, apiKey }; - const host = makeRefreshHost({ - providers: { - a: { - type: 'openai', - baseUrl: 'https://a.example.test/v1', - apiKey, - source, - }, - }, - models: { - 'a/m1': { - provider: 'a', - model: 'm1', - maxContextSize: 131072, - capabilities: ['tool_use'], - displayName: 'm1', - }, - }, - telemetry: true, - } as unknown as PythinkerConfig); - - const fetchMock = vi.fn(async (input, init) => { - expect(fetchInputUrl(input)).toBe(registryUrl); - expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token'); - return new Response( - JSON.stringify({ - a: { - id: 'a', - name: 'Provider A', - api: 'https://a.example.test/v1', - type: 'openai', - models: { m1: { id: 'm1' } }, - }, - b: { - id: 'b', - name: 'Provider B', - api: 'https://b.example.test/v1', - type: 'openai', - models: { m1: { id: 'm1' } }, - }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - }); - vi.stubGlobal('fetch', fetchMock); - - const result = await refreshAllProviderModels({ - getConfig: async () => host.current(), - removeProvider: host.removeProvider, - setConfig: host.setConfig, - }); - - expect(result.failed).toEqual([]); - expect(result.unchanged).toEqual(['a']); - expect(result.changed).toEqual([ - { - providerId: 'b', - providerName: 'Provider B', - added: 1, - removed: 0, - }, - ]); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(host.removeProvider).not.toHaveBeenCalled(); - expect(host.setConfig).toHaveBeenCalledTimes(1); - expect(Object.keys(host.current().providers).toSorted()).toEqual(['a', 'b']); - expect(host.current().providers['b']).toMatchObject({ - type: 'openai', - baseUrl: 'https://b.example.test/v1', - apiKey, - source, - }); - expect(host.current().models?.['b/m1']).toEqual({ - provider: 'b', - model: 'm1', - maxContextSize: 131072, - capabilities: ['tool_use'], - displayName: 'm1', - }); - }); - - it('removes custom-registry providers that disappear from an existing source URL', async () => { - const registryUrl = 'https://registry.example.test/v1/models/api.json'; - const apiKey = 'sk-test-token'; - const source = { kind: 'apiJson', url: registryUrl, apiKey }; - const host = makeRefreshHost({ - providers: { - a: { - type: 'openai', - baseUrl: 'https://a.example.test/v1', - apiKey, - source, - }, - b: { - type: 'openai', - baseUrl: 'https://b.example.test/v1', - apiKey, - source, - }, - }, - models: { - 'a/m1': { - provider: 'a', - model: 'm1', - maxContextSize: 131072, - capabilities: ['tool_use'], - displayName: 'm1', - }, - 'b/m1': { - provider: 'b', - model: 'm1', - maxContextSize: 131072, - capabilities: ['tool_use'], - displayName: 'm1', - }, - 'my-b': { - provider: 'b', - model: 'm1', - maxContextSize: 131072, - capabilities: ['tool_use'], - displayName: 'My B', - }, - }, - defaultModel: 'my-b', - thinking: { enabled: true }, - telemetry: true, - } as unknown as PythinkerConfig); - - const fetchMock = vi.fn(async (input, init) => { - expect(fetchInputUrl(input)).toBe(registryUrl); - expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token'); - return new Response( - JSON.stringify({ - a: { - id: 'a', - name: 'Provider A', - api: 'https://a.example.test/v1', - type: 'openai', - models: { m1: { id: 'm1' } }, - }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - }); - vi.stubGlobal('fetch', fetchMock); - - const result = await refreshAllProviderModels({ - getConfig: async () => host.current(), - removeProvider: host.removeProvider, - setConfig: host.setConfig, - }); - - expect(result.failed).toEqual([]); - expect(result.unchanged).toEqual(['a']); - expect(result.changed).toEqual([ - { - providerId: 'b', - providerName: 'b', - added: 0, - removed: 1, - }, - ]); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(host.removeProvider).toHaveBeenCalledWith('b'); - expect(host.setConfig).toHaveBeenCalledTimes(1); - expect(Object.keys(host.current().providers)).toEqual(['a']); - expect(host.current().models?.['a/m1']).toBeDefined(); - expect(host.current().models?.['b/m1']).toBeUndefined(); - expect(host.current().models?.['my-b']).toBeUndefined(); - expect(host.current().defaultModel).toBeUndefined(); - expect(host.current().thinking).toBeUndefined(); - }); - - it('coalesces duplicate custom-registry source URLs without reporting config-only changes', async () => { - const registryUrl = 'https://registry.example.test/v1/models/api.json'; - const oldSource = { kind: 'apiJson', url: registryUrl, apiKey: 'sk-old-token' }; - const newSource = { kind: 'apiJson', url: registryUrl, apiKey: 'sk-new-token' }; - const host = makeRefreshHost({ - providers: { - a: { - type: 'openai', - baseUrl: 'https://a.example.test/v1', - apiKey: 'sk-old-token', - source: oldSource, - }, - b: { - type: 'openai', - baseUrl: 'https://b.example.test/v1', - apiKey: 'sk-new-token', - source: newSource, - }, - }, - models: { - 'a/m1': { - provider: 'a', - model: 'm1', - maxContextSize: 131072, - capabilities: ['tool_use'], - displayName: 'm1', - }, - 'b/m1': { - provider: 'b', - model: 'm1', - maxContextSize: 131072, - capabilities: ['tool_use'], - displayName: 'm1', - }, - }, - telemetry: true, - } as unknown as PythinkerConfig); - - const fetchMock = vi.fn(async (input, init) => { - expect(fetchInputUrl(input)).toBe(registryUrl); - const authorization = new Headers(init?.headers).get('authorization'); - if (authorization === 'Bearer sk-old-token') { - return new Response(JSON.stringify({ message: 'expired token' }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }); - } - expect(authorization).toBe('Bearer sk-new-token'); - return new Response( - JSON.stringify({ - a: { - id: 'a', - name: 'Provider A', - api: 'https://a.example.test/v1', - type: 'openai', - models: { m1: { id: 'm1' } }, - }, - b: { - id: 'b', - name: 'Provider B', - api: 'https://b.example.test/v1', - type: 'openai', - models: { m1: { id: 'm1' }, m2: { id: 'm2' } }, - }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - }); - vi.stubGlobal('fetch', fetchMock); - - const result = await refreshAllProviderModels({ - getConfig: async () => host.current(), - removeProvider: host.removeProvider, - setConfig: host.setConfig, - }); - - expect(result.failed).toEqual([]); - expect(result.unchanged).toEqual(['a']); - expect(result.changed).toEqual([ - { - providerId: 'b', - providerName: 'Provider B', - added: 1, - removed: 0, - }, - ]); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(host.removeProvider).toHaveBeenCalledWith('a'); - expect(host.removeProvider).toHaveBeenCalledWith('b'); - expect(host.setConfig).toHaveBeenCalledTimes(1); - expect(host.current().providers['a']?.source).toEqual(newSource); - expect(host.current().providers['b']?.source).toEqual(newSource); - expect(host.current().providers['a']?.apiKey).toBe('sk-new-token'); - expect(host.current().providers['b']?.apiKey).toBe('sk-new-token'); - expect(host.current().models?.['b/m2']).toEqual({ - provider: 'b', - model: 'm2', - maxContextSize: 131072, - capabilities: ['tool_use'], - displayName: 'm2', - }); - }); - - it('ignores user-defined aliases when custom-registry metadata is unchanged', async () => { - const registryUrl = 'https://registry.example.test/v1/models/api.json'; - const providerId = 'example_chat-completions'; - const modelId = 'reasoner-pro'; - const modelAlias = `${providerId}/${modelId}`; - const userAlias = 'my-reasoner'; - const richCapabilities = ['tool_use', 'thinking', 'image_in']; - const userAliasModel = { - provider: providerId, - model: modelId, - maxContextSize: 262144, - capabilities: ['tool_use'], - displayName: 'My Reasoner', - }; - const host = makeRefreshHost({ - providers: { - [providerId]: { - type: 'openai', - baseUrl: 'https://api.example.test/v1', - apiKey: 'sk-test-token', - source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, - }, - }, - models: { - [modelAlias]: { - provider: providerId, - model: modelId, - maxContextSize: 262144, - capabilities: richCapabilities, - displayName: 'Reasoner Pro', - }, - [userAlias]: userAliasModel, - }, - defaultModel: userAlias, - thinking: { enabled: false }, - telemetry: true, - } as unknown as PythinkerConfig); - - const fetchMock = vi.fn(async (input, init) => { - expect(fetchInputUrl(input)).toBe(registryUrl); - expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token'); - return new Response( - JSON.stringify({ - [providerId]: { - id: providerId, - name: 'Example Chat Completions', - api: 'https://api.example.test/v1', - type: 'openai', - models: { - [modelId]: { - id: modelId, - name: 'Reasoner Pro', - limit: { context: 262144, output: 262144 }, - tool_call: true, - reasoning: true, - modalities: { input: ['text', 'image'], output: ['text'] }, - }, - }, - }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - }); - vi.stubGlobal('fetch', fetchMock); - - const result = await refreshAllProviderModels({ - getConfig: async () => host.current(), - removeProvider: host.removeProvider, - setConfig: host.setConfig, - }); - - expect(result.failed).toEqual([]); - expect(result.changed).toEqual([]); - expect(result.unchanged).toEqual([providerId]); - expect(host.removeProvider).not.toHaveBeenCalled(); - expect(host.setConfig).not.toHaveBeenCalled(); - expect(host.current().models?.[userAlias]).toEqual(userAliasModel); - expect(host.current().defaultModel).toBe(userAlias); - expect(host.current().thinking?.enabled).toBe(false); - }); - -}); diff --git a/apps/pythinker-code/test/utils/git/git-status.test.ts b/apps/pythinker-code/test/utils/git/git-status.test.ts index d74b82f41..38cdaa364 100644 --- a/apps/pythinker-code/test/utils/git/git-status.test.ts +++ b/apps/pythinker-code/test/utils/git/git-status.test.ts @@ -1,25 +1,59 @@ /* eslint-disable import/first -- vi.mock setup must run before the imports it stubs out. */ +import { join } from 'node:path'; + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ - spawnSync: vi.fn(), execFile: vi.fn(), resolveCommandPath: vi.fn(), + watchers: [] as Array<{ + options: unknown; + add: ReturnType; + close: ReturnType; + emit: (event: string) => void; + }>, })); vi.mock('node:child_process', () => ({ execFile: mocks.execFile, - spawnSync: mocks.spawnSync, })); vi.mock('#/utils/process/resolve-command', () => ({ resolveCommandPath: mocks.resolveCommandPath, })); +vi.mock('chokidar', () => { + class FSWatcher { + options: unknown; + add = vi.fn(); + close = vi.fn(() => Promise.resolve()); + private listeners = new Map void>>(); + + constructor(options: unknown) { + this.options = options; + mocks.watchers.push(this); + } + + on(event: string, cb: () => void): this { + const list = this.listeners.get(event) ?? []; + list.push(cb); + this.listeners.set(event, list); + return this; + } + + emit(event: string): void { + for (const cb of this.listeners.get(event) ?? []) cb(); + } + } + + return { FSWatcher }; +}); + import { createGitStatusCache, formatGitBadge } from '#/utils/git/git-status'; beforeEach(() => { mocks.resolveCommandPath.mockImplementation((command: string) => `/usr/bin/${command}`); + mocks.watchers.length = 0; }); afterEach(() => { @@ -27,40 +61,93 @@ afterEach(() => { vi.clearAllMocks(); }); -describe('git status cache', () => { - it('caches branch and status reads until their TTL expires', async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-04-24T00:00:00Z')); - mocks.execFile.mockImplementation( - ( - _cmd: string, - _args: string[], - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void, - ) => { - callback(new Error('no pull request'), '', ''); - }, - ); - mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => { - if (args.includes('rev-parse')) { - return { status: 0, stdout: 'true\n' }; +async function drain(): Promise { + for (let i = 0; i < 30; i++) await Promise.resolve(); +} + +function gitCalls(subcommand: string): unknown[][] { + return mocks.execFile.mock.calls.filter((call) => { + const args = call[1] as string[]; + return call[0] === '/usr/bin/git' && args.includes(subcommand); + }); +} + +function installGit(scripts: { + gitDir?: string | null; + status?: string | null; + diff?: string | null; + ghError?: Error | null; + ghStdout?: string; + holdStatus?: boolean; +}): { releaseStatus: (stdout: string | null) => void } { + let releaseStatus: (stdout: string | null) => void = () => {}; + mocks.execFile.mockImplementation( + ( + cmd: string, + args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + if (cmd === '/usr/bin/gh') { + if (scripts.ghError !== undefined && scripts.ghError !== null) { + callback(scripts.ghError, '', ''); + return; + } + callback(null, scripts.ghStdout ?? '', ''); + return; } - if (args.includes('branch')) { - return { status: 0, stdout: 'main\n' }; + if (args.includes('rev-parse')) { + if (scripts.gitDir === null) { + callback(Object.assign(new Error('not a repo'), { code: 128 }), '', ''); + return; + } + callback(null, `${scripts.gitDir ?? 'C:/repo/.git'}\n`, ''); + return; } if (args.includes('status')) { - return { - status: 0, - stdout: '## main...origin/main [ahead 2, behind 1]\n M src/app.ts\n', - }; + if (scripts.holdStatus === true) { + releaseStatus = (stdout) => { + if (stdout === null) { + callback(Object.assign(new Error('status failed'), { code: 1 }), '', ''); + return; + } + callback(null, stdout, ''); + }; + return; + } + if (scripts.status === null) { + callback(Object.assign(new Error('status failed'), { code: 1 }), '', ''); + return; + } + callback(null, scripts.status ?? '## main\n', ''); + return; } if (args.includes('diff')) { - return { status: 0, stdout: '4\t1\tsrc/app.ts\n' }; + callback(null, scripts.diff ?? '', ''); + return; } - return { status: 1, stdout: '' }; + callback(new Error('unexpected git command'), '', ''); + }, + ); + return { releaseStatus: (stdout) => releaseStatus(stdout) }; +} + +describe('git status cache', () => { + it('reads the cache without blocking and refreshes status on a 30s TTL', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-24T00:00:00Z')); + installGit({ + status: '## main...origin/main [ahead 2, behind 1]\n M src/app.ts\n', + diff: '4\t1\tsrc/app.ts\n', + ghError: new Error('no pull request'), }); const cache = createGitStatusCache('/tmp/repo'); + expect(mocks.execFile).not.toHaveBeenCalled(); + expect(cache.getStatus()).toBeNull(); + expect(mocks.execFile).not.toHaveBeenCalled(); + + await drain(); expect(cache.getStatus()).toEqual({ branch: 'main', @@ -71,79 +158,36 @@ describe('git status cache', () => { diffDeleted: 1, pullRequest: null, }); - expect(cache.getStatus()).toEqual({ - branch: 'main', - dirty: true, - ahead: 2, - behind: 1, - diffAdded: 4, - diffDeleted: 1, - pullRequest: null, - }); - expect(mocks.spawnSync).toHaveBeenCalledTimes(4); - expect(mocks.execFile).toHaveBeenCalledTimes(1); - - await Promise.resolve(); + expect(gitCalls('rev-parse')).toHaveLength(1); + expect(gitCalls('status')).toHaveLength(1); + expect(gitCalls('diff')).toHaveLength(1); + expect(gitCalls('branch')).toHaveLength(0); + expect(mocks.execFile).toHaveBeenCalledTimes(4); - vi.setSystemTime(new Date('2026-04-24T00:00:06Z')); cache.getStatus(); - expect(mocks.spawnSync).toHaveBeenCalledTimes(5); - expect(mocks.execFile).toHaveBeenCalledTimes(1); + await drain(); + expect(gitCalls('status')).toHaveLength(1); - vi.setSystemTime(new Date('2026-04-24T00:00:16Z')); + vi.setSystemTime(new Date('2026-04-24T00:00:30Z')); cache.getStatus(); - expect(mocks.spawnSync).toHaveBeenCalledTimes(8); - expect(mocks.execFile).toHaveBeenCalledTimes(1); + await drain(); + expect(gitCalls('status')).toHaveLength(2); + expect(gitCalls('rev-parse')).toHaveLength(1); + cache.dispose(); }); it('reads uncommitted diff line counts and current pull request metadata', async () => { const onChange = vi.fn(); - mocks.execFile.mockImplementation( - ( - _cmd: string, - _args: string[], - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void, - ) => { - callback(null, '{"number":12,"url":"https://github.com/acme/repo/pull/12"}\n', ''); - }, - ); - mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => { - if (args.includes('rev-parse')) { - return { status: 0, stdout: 'true\n' }; - } - if (args.includes('branch')) { - return { status: 0, stdout: 'feature/footer\n' }; - } - if (args.includes('status')) { - return { - status: 0, - stdout: '## feature/footer...origin/feature/footer\n M src/app.ts\n', - }; - } - if (args.includes('diff')) { - return { - status: 0, - stdout: '10\t3\tsrc/app.ts\n-\t-\timage.png\n0\t5\tdeleted.ts\n', - }; - } - return { status: 1, stdout: '' }; + installGit({ + status: '## feature/footer...origin/feature/footer\n M src/app.ts\n', + diff: '10\t3\tsrc/app.ts\n-\t-\timage.png\n0\t5\tdeleted.ts\n', + ghStdout: '{"number":12,"url":"https://github.com/acme/repo/pull/12"}\n', }); const cache = createGitStatusCache('/tmp/repo', { onChange }); - expect(cache.getStatus()).toEqual({ - branch: 'feature/footer', - dirty: true, - ahead: 0, - behind: 0, - diffAdded: 10, - diffDeleted: 8, - pullRequest: null, - }); - - await Promise.resolve(); + cache.getStatus(); + await drain(); - expect(onChange).toHaveBeenCalledTimes(1); expect(cache.getStatus()).toEqual({ branch: 'feature/footer', dirty: true, @@ -156,34 +200,32 @@ describe('git status cache', () => { url: 'https://github.com/acme/repo/pull/12', }, }); + expect(onChange).toHaveBeenCalled(); + cache.dispose(); }); - it('keeps footer git status working when gh pull-request lookup throws synchronously', async () => { + it('keeps the last status when gh lookup throws synchronously', async () => { const onChange = vi.fn(); - mocks.execFile.mockImplementation(() => { - const error = Object.assign(new Error('spawn ENOTDIR'), { code: 'ENOTDIR' }); - throw error; - }); - mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => { - if (args.includes('rev-parse')) { - return { status: 0, stdout: 'true\n' }; + mocks.execFile.mockImplementation((cmd: string, args: string[], _options: unknown, callback: Function) => { + if (cmd === '/usr/bin/gh') { + throw Object.assign(new Error('spawn ENOTDIR'), { code: 'ENOTDIR' }); } - if (args.includes('branch')) { - return { status: 0, stdout: 'main\n' }; + if (args.includes('rev-parse')) { + callback(null, 'C:/repo/.git\n', ''); + return; } if (args.includes('status')) { - return { - status: 0, - stdout: '## main...origin/main\n M src/app.ts\n', - }; + callback(null, '## main...origin/main\n M src/app.ts\n', ''); + return; } if (args.includes('diff')) { - return { status: 0, stdout: '2\t1\tsrc/app.ts\n' }; + callback(null, '2\t1\tsrc/app.ts\n', ''); } - return { status: 1, stdout: '' }; }); const cache = createGitStatusCache('/tmp/repo', { onChange }); + cache.getStatus(); + await drain(); expect(cache.getStatus()).toEqual({ branch: 'main', @@ -194,65 +236,71 @@ describe('git status cache', () => { diffDeleted: 1, pullRequest: null, }); - - await Promise.resolve(); - - expect(onChange).not.toHaveBeenCalled(); - expect(cache.getStatus()).toEqual({ - branch: 'main', - dirty: true, - ahead: 0, - behind: 0, - diffAdded: 2, - diffDeleted: 1, - pullRequest: null, - }); + expect(onChange).toHaveBeenCalledTimes(1); + cache.dispose(); }); it('returns null without spawning when git cannot be resolved to a safe path', () => { mocks.resolveCommandPath.mockReturnValue(undefined); - expect(createGitStatusCache('/tmp/repo').getStatus()).toBeNull(); - expect(mocks.spawnSync).not.toHaveBeenCalled(); + const cache = createGitStatusCache('/tmp/repo'); + expect(cache.getStatus()).toBeNull(); expect(mocks.execFile).not.toHaveBeenCalled(); + expect(mocks.watchers).toHaveLength(0); + cache.dispose(); }); - it('spawns git and gh through their resolved absolute paths', async () => { - mocks.execFile.mockImplementation( - ( - _cmd: string, - _args: string[], - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void, - ) => { - callback(new Error('no pull request'), '', ''); - }, - ); - mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => { - if (args.includes('rev-parse')) return { status: 0, stdout: 'true\n' }; - if (args.includes('branch')) return { status: 0, stdout: 'main\n' }; - if (args.includes('status')) return { status: 0, stdout: '## main...origin/main\n' }; - return { status: 1, stdout: '' }; + it('spawns git and gh through their resolved absolute paths and polls only HEAD', async () => { + installGit({ + gitDir: 'C:/repo/.git', + status: '## main...origin/main\n', + ghError: new Error('no pull request'), }); const cache = createGitStatusCache('/tmp/repo'); - expect(cache.getStatus()).not.toBeNull(); - await Promise.resolve(); + expect(mocks.execFile).not.toHaveBeenCalled(); + cache.getStatus(); + expect(mocks.execFile).not.toHaveBeenCalled(); + await drain(); expect(mocks.resolveCommandPath).toHaveBeenCalledWith('git', '/tmp/repo'); - for (const call of mocks.spawnSync.mock.calls) { - expect(call[0]).toBe('/usr/bin/git'); - } + expect(mocks.execFile).toHaveBeenCalledWith( + '/usr/bin/git', + ['-C', '/tmp/repo', 'rev-parse', '--absolute-git-dir'], + expect.anything(), + expect.any(Function), + ); expect(mocks.execFile).toHaveBeenCalledWith( '/usr/bin/gh', expect.any(Array), expect.anything(), expect.any(Function), ); + expect(mocks.watchers).toHaveLength(1); + expect(mocks.watchers[0]?.options).toEqual( + expect.objectContaining({ usePolling: true, interval: 2_000, ignoreInitial: true }), + ); + expect(mocks.watchers[0]?.add).toHaveBeenCalledWith(join('C:/repo/.git', 'HEAD')); + cache.dispose(); + expect(mocks.watchers[0]?.close).toHaveBeenCalledTimes(1); }); - it('returns null when the working directory is not a git repo and formats badges', () => { - mocks.spawnSync.mockReturnValue({ status: 1, stdout: '' }); - expect(createGitStatusCache('/tmp/not-a-repo').getStatus()).toBeNull(); + it('returns null when the working directory is not a git repo and does not probe again', async () => { + installGit({ gitDir: null }); + const cache = createGitStatusCache('/tmp/not-a-repo'); + expect(cache.getStatus()).toBeNull(); + await drain(); + + expect(cache.getStatus()).toBeNull(); + expect(gitCalls('rev-parse')).toHaveLength(1); + expect(mocks.watchers).toHaveLength(0); + cache.getStatus(); + cache.refresh(); + await drain(); + expect(gitCalls('rev-parse')).toHaveLength(1); + cache.dispose(); + }); + + it('formats badges', () => { expect( formatGitBadge({ branch: 'main', @@ -277,16 +325,88 @@ describe('git status cache', () => { ).toBe('main [±]'); }); - it('skips git on later renders once the directory is known to be a non-repo', () => { - mocks.spawnSync.mockReturnValue({ status: 1, stdout: '' }); - const cache = createGitStatusCache('/tmp/not-a-repo'); + it('parses an unborn branch and hides detached HEAD', async () => { + installGit({ status: '## No commits yet on main\n' }); + const unborn = createGitStatusCache('/tmp/repo'); + unborn.getStatus(); + await drain(); + expect(unborn.getStatus()?.branch).toBe('main'); + unborn.dispose(); - expect(cache.getStatus()).toBeNull(); - const probeCalls = mocks.spawnSync.mock.calls.length; - expect(probeCalls).toBe(1); + mocks.watchers.length = 0; + installGit({ status: '## HEAD (no branch)\n' }); + const detached = createGitStatusCache('/tmp/repo'); + detached.getStatus(); + await drain(); + expect(detached.getStatus()).toBeNull(); + expect(gitCalls('diff')).toHaveLength(0); + detached.dispose(); + }); - expect(cache.getStatus()).toBeNull(); - expect(mocks.spawnSync.mock.calls.length).toBe(probeCalls); + it('refreshes when HEAD changes and when refresh() is called inside the TTL', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-24T00:00:00Z')); + installGit({ status: '## main\n', ghError: new Error('no pull request') }); + const cache = createGitStatusCache('/tmp/repo'); + cache.getStatus(); + await drain(); + expect(gitCalls('status')).toHaveLength(1); + + mocks.watchers[0]?.emit('change'); + await drain(); + expect(gitCalls('status')).toHaveLength(2); + + cache.refresh(); + await drain(); + expect(gitCalls('status')).toHaveLength(3); + expect(gitCalls('rev-parse')).toHaveLength(1); + cache.dispose(); + }); + + it('keeps the last good status when a later status command fails', async () => { + installGit({ + status: '## main\n M src/app.ts\n', + diff: '2\t1\tsrc/app.ts\n', + ghError: new Error('no pull request'), + }); + const cache = createGitStatusCache('/tmp/repo'); + cache.getStatus(); + await drain(); + + installGit({ status: null, ghError: new Error('no pull request') }); + cache.refresh(); + await drain(); + + expect(cache.getStatus()).toEqual({ + branch: 'main', + dirty: true, + ahead: 0, + behind: 0, + diffAdded: 2, + diffDeleted: 1, + pullRequest: null, + }); + cache.dispose(); + }); + + it('coalesces a forced refresh that arrives while one is in flight', async () => { + const held = installGit({ holdStatus: true, ghError: new Error('no pull request') }); + const cache = createGitStatusCache('/tmp/repo'); + cache.getStatus(); + await drain(); + expect(gitCalls('status')).toHaveLength(1); + + cache.refresh(); + cache.refresh(); + held.releaseStatus('## main\n'); + await drain(); + expect(gitCalls('status')).toHaveLength(2); + + held.releaseStatus('## topic\n'); + await drain(); + expect(cache.getStatus()?.branch).toBe('topic'); + expect(gitCalls('status')).toHaveLength(2); + cache.dispose(); }); it('formats pull request badges as terminal hyperlinks when requested', () => { diff --git a/apps/pythinker-code/test/utils/model-migration-state.test.ts b/apps/pythinker-code/test/utils/model-migration-state.test.ts new file mode 100644 index 000000000..5243fe3fe --- /dev/null +++ b/apps/pythinker-code/test/utils/model-migration-state.test.ts @@ -0,0 +1,51 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + acknowledgeModelMigration, + readModelMigrationAcknowledgements, +} from '#/utils/model-migration-state'; + +describe('model-migration-state', () => { + let dir: string; + let file: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'pythinker-model-migration-state-')); + file = join(dir, 'model-migrations.json'); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('reads no acknowledgements when the file is missing', async () => { + await expect(readModelMigrationAcknowledgements(file)).resolves.toEqual({}); + }); + + it('merges acknowledgements into the stored record', async () => { + await acknowledgeModelMigration('acme/current', 'next', file); + await acknowledgeModelMigration('other/current', 'newer', file); + await acknowledgeModelMigration('acme/current', 'latest', file); + + await expect(readModelMigrationAcknowledgements(file)).resolves.toEqual({ + 'acme/current': 'latest', + 'other/current': 'newer', + }); + expect(JSON.parse(await readFile(file, 'utf-8'))).toEqual({ + version: 1, + acknowledged: { 'acme/current': 'latest', 'other/current': 'newer' }, + }); + }); + + it.each([ + ['corrupt', 'not json'], + ['mismatched', JSON.stringify({ version: 2, acknowledged: {} })], + ])('reads no acknowledgements from a %s file', async (_case, text) => { + await writeFile(file, text, 'utf-8'); + await expect(readModelMigrationAcknowledgements(file)).resolves.toEqual({}); + }); +}); diff --git a/apps/pythinker-code/test/utils/process/resolve-command.test.ts b/apps/pythinker-code/test/utils/process/resolve-command.test.ts index 1bff7b5d5..7fc610447 100644 --- a/apps/pythinker-code/test/utils/process/resolve-command.test.ts +++ b/apps/pythinker-code/test/utils/process/resolve-command.test.ts @@ -1,6 +1,6 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, parse } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -29,6 +29,18 @@ function mockPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { value: platform }); } +it('resolves a PATH executable from the filesystem root', () => { + const bin = makeTempDir('pythinker-resolve-bin-'); + const name = process.platform === 'win32' ? 'mytool.EXE' : 'mytool'; + const tool = join(bin, name); + writeFileSync(tool, ''); + chmodSync(tool, 0o755); + process.env['PATH'] = bin; + process.env['PATHEXT'] = '.EXE'; + + expect(resolveCommandPath(name, parse(bin).root)).toBe(tool); +}); + describe('resolveCommandPath (posix)', () => { // Executable-bit checks only work on a posix host. it.skipIf(process.platform === 'win32')('resolves an executable from PATH to an absolute path', () => { @@ -86,6 +98,17 @@ describe('resolveCommandPath (posix)', () => { expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); }); + it.skipIf(process.platform === 'win32')('continues to a safe PATH hit after a workspace executable', () => { + const cwd = makeTempDir('pythinker-resolve-cwd-'); + const bin = makeTempDir('pythinker-resolve-bin-'); + for (const dir of [cwd, bin]) { + writeFileSync(join(dir, 'mytool'), '#!/bin/sh\nexit 0\n'); + chmodSync(join(dir, 'mytool'), 0o755); + } + process.env['PATH'] = `${cwd}:${bin}`; + expect(resolveCommandPath('mytool', cwd)).toBe(join(bin, 'mytool')); + }); + it('returns undefined when the command is not on PATH', () => { const bin = makeTempDir('pythinker-resolve-bin-'); const cwd = makeTempDir('pythinker-resolve-cwd-'); diff --git a/apps/vscode/scripts/watch-extension.mjs b/apps/vscode/scripts/watch-extension.mjs index 0ed83cefe..f8d2849b4 100644 --- a/apps/vscode/scripts/watch-extension.mjs +++ b/apps/vscode/scripts/watch-extension.mjs @@ -10,7 +10,6 @@ const sourceDirectories = [ join(extensionRoot, 'src'), join(extensionRoot, 'shared'), ...[ - 'pyaos', 'kosong', 'migration-legacy', 'node-sdk', diff --git a/apps/vscode/src/runtime/replay-adapter.ts b/apps/vscode/src/runtime/replay-adapter.ts index 1202db1ad..601eaa169 100644 --- a/apps/vscode/src/runtime/replay-adapter.ts +++ b/apps/vscode/src/runtime/replay-adapter.ts @@ -95,7 +95,10 @@ function replayAgentToWebviewEvents( const message = record.message; if (message.role === "user") { if (!isVisibleUserMessage(message.origin)) break; - const visibleContent = withoutUserPromptSubmitHookParts(message.content); + const visibleContent = stripBundledSkillParts( + withoutUserPromptSubmitHookParts(message.content), + message.origin, + ); const imported = importedContextReplay(visibleContent); completeTurn(); step = 0; @@ -530,6 +533,38 @@ function toLegacyContent(content: readonly ContentPart[]): LegacyContentPart[] { return result; } +function isSkillActivationPart(part: ContentPart): boolean { + return ( + part.type === "text" && + (part as { meta?: { source?: unknown } }).meta?.source === "skill activation" + ); +} + +function stripBundledSkillParts( + content: readonly ContentPart[], + origin: PromptOrigin | undefined, +): ContentPart[] { + let remainingSkillParts = + origin?.kind === "user" && + "skillActivations" in origin && + Array.isArray(origin.skillActivations) && + !content.some(isSkillActivationPart) + ? origin.skillActivations.length + : 0; + return content.filter((part) => { + if (isSkillActivationPart(part)) return false; + if ( + remainingSkillParts > 0 && + part.type === "text" && + (part as { meta?: { source?: unknown } }).meta?.source === undefined + ) { + remainingSkillParts--; + return false; + } + return true; + }); +} + function isVisibleUserMessage(origin: PromptOrigin | undefined): boolean { if (origin === undefined || origin.kind === "user") return true; if (origin.kind === "skill_activation" || origin.kind === "plugin_command") { diff --git a/apps/vscode/test/replay-adapter.test.ts b/apps/vscode/test/replay-adapter.test.ts index c3f7a759e..bfe813a6a 100644 --- a/apps/vscode/test/replay-adapter.test.ts +++ b/apps/vscode/test/replay-adapter.test.ts @@ -463,6 +463,64 @@ describe("replay adapter (renders the public SDK resume state for the Webview)", }); }); + it.each([ + { input: "/skill:review src/app.ts", skills: [{ skillName: "review", skillArgs: "src/app.ts" }] }, + { input: "Review /skill:review src/app.ts", skills: [{ skillName: "review", skillArgs: "" }] }, + { + input: "/skill:review /skill:security src/app.ts", + skills: [{ skillName: "review", skillArgs: "" }, { skillName: "security", skillArgs: "" }], + }, + { input: "/skill:review /skill:review src/app.ts", skills: [{ skillName: "review", skillArgs: "" }] }, + ].flatMap((fixture) => [ + { ...fixture, format: "marked" }, + { ...fixture, format: "legacy" }, + ]))("restores bundled skill input and media without expanded instructions ($input, $format)", ({ input, skills, format }) => { + const skillActivations = skills.map((skill, index) => ({ + ...skill, + activationId: `activation-${index + 1}`, + trigger: "user-slash" as const, + })); + const content: ContentPart[] = skillActivations.map((activation) => ({ + type: "text", + text: `Skill instructions.`, + meta: format === "marked" + ? { source: "skill activation", activationId: activation.activationId } + : undefined, + })); + content.push( + { type: "text", text: input }, + { type: "image_url", imageUrl: { url: "file:///workspace/a.png", id: "image-1" } }, + { type: "video_url", videoUrl: { url: "file:///workspace/a.mp4", id: "video-1" } }, + ); + const origin = { kind: "user" as const, skillActivations }; + const events = replay([record(message("user", content, { origin }))]); + + expect(events).toContainEqual({ + type: "TurnBegin", + payload: { + user_input: [ + { type: "text", text: input }, + { type: "image_url", image_url: { url: "file:///workspace/a.png", id: "image-1" } }, + { type: "video_url", video_url: { url: "file:///workspace/a.mp4", id: "video-1" } }, + ], + }, + _sessionId: "session-1", + }); + }); + + it("keeps user-authored skill XML without activation metadata", () => { + const input = 'Keep this quoted text.'; + const events = replay([ + record(message("user", [{ type: "text", text: input }], { origin: { kind: "user" } })), + ]); + + expect(events).toContainEqual({ + type: "TurnBegin", + payload: { user_input: [{ type: "text", text: input }] }, + _sessionId: "session-1", + }); + }); + it("restores imported context as the original command and confirmation", () => { const events = replay([ record( diff --git a/apps/vscode/test/webview/QuestionDialog.test.tsx b/apps/vscode/test/webview/QuestionDialog.test.tsx new file mode 100644 index 000000000..32ccafffd --- /dev/null +++ b/apps/vscode/test/webview/QuestionDialog.test.tsx @@ -0,0 +1,378 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QuestionDialog } from '@/components/QuestionDialog'; +import { useChatStore } from '@/stores'; +import type { QuestionItem } from 'shared/legacy-sdk'; + +const respondQuestion = vi.fn().mockResolvedValue(undefined); + +function makeQuestion(question: string, labels: string[], multiSelect = false): QuestionItem { + return { + question, + options: labels.map((label) => ({ label })), + multi_select: multiSelect, + }; +} + +function setPendingQuestion(questions: QuestionItem[], id = 'request-1') { + useChatStore.setState({ + pendingQuestion: { id, tool_call_id: 'tool-call-1', questions }, + respondQuestion, + }); +} + +function renderQuestion(questions: QuestionItem[], id?: string) { + setPendingQuestion(questions, id); + return render(); +} + +function optionButton(label: string) { + const button = screen.getByText(label).closest('button'); + if (!button) throw new Error(`No button contains ${label}`); + return button; +} + +function customResponseButton() { + return screen.getByRole('button', { name: /Custom response/ }) as HTMLButtonElement; +} + +function customInput() { + return screen.getByPlaceholderText(/Enter your response/) as HTMLInputElement; +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('QuestionDialog', () => { + beforeEach(() => { + respondQuestion.mockReset(); + respondQuestion.mockResolvedValue(undefined); + useChatStore.setState({ pendingQuestion: null, respondQuestion }); + }); + + it('renders nothing without a pending question or question items', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + + act(() => { + setPendingQuestion([]); + }); + expect(container.firstChild).toBeNull(); + }); + + it('preserves single-select answers when navigating backward and forward', async () => { + renderQuestion([ + { + ...makeQuestion('First question', ['Alpha', 'Beta']), + header: 'Setup', + options: [{ label: 'Alpha', description: 'First option' }, { label: 'Beta' }], + }, + makeQuestion('Second question', ['Gamma', 'Delta']), + makeQuestion('Final question', ['Yes', 'No']), + ]); + + expect(screen.getByText('Question 1 of 3')).toBeTruthy(); + expect(screen.getByText('Setup')).toBeTruthy(); + expect(screen.getByText('- First option')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Back' })).toBeNull(); + + fireEvent.click(optionButton('Alpha')); + fireEvent.click(optionButton('Gamma')); + expect(screen.getByText('Final question')).toBeTruthy(); + expect(respondQuestion).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(screen.getByText('Second question')).toBeTruthy(); + expect(within(optionButton('Gamma')).getByText('✓')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(screen.getByText('First question')).toBeTruthy(); + expect(within(optionButton('Alpha')).getByText('✓')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + fireEvent.click(optionButton('No')); + + await waitFor(() => { + expect(respondQuestion).toHaveBeenCalledWith({ + 'First question': 'Alpha', + 'Second question': 'Gamma', + 'Final question': 'No', + }); + }); + }); + + it('preserves custom drafts per question when navigating backward and forward', () => { + renderQuestion([ + makeQuestion('First question', ['Alpha']), + makeQuestion('Second question', ['Gamma']), + ]); + + fireEvent.click(optionButton('Alpha')); + fireEvent.click(customResponseButton()); + fireEvent.change(customInput(), { target: { value: 'Second draft' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + fireEvent.click(customResponseButton()); + expect(customInput().value).toBe(''); + fireEvent.change(customInput(), { target: { value: 'First draft' } }); + fireEvent.keyDown(customInput(), { key: 'Escape' }); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + fireEvent.click(customResponseButton()); + expect(customInput().value).toBe('Second draft'); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + fireEvent.click(customResponseButton()); + expect(customInput().value).toBe('First draft'); + }); + + it('replaces an earlier single-select answer after going back', async () => { + renderQuestion([ + makeQuestion('First question', ['Alpha', 'Beta']), + makeQuestion('Final question', ['Gamma', 'Delta']), + ]); + + fireEvent.click(optionButton('Alpha')); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + fireEvent.click(optionButton('Beta')); + fireEvent.click(optionButton('Delta')); + + await waitFor(() => { + expect(respondQuestion).toHaveBeenCalledWith({ + 'First question': 'Beta', + 'Final question': 'Delta', + }); + }); + }); + + it('toggles multi-select options and submits them in selection order', async () => { + renderQuestion([makeQuestion('Pick several', ['Alpha', 'Beta', 'Gamma'], true)]); + + const submit = screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement; + expect(submit.disabled).toBe(true); + expect(screen.getByText('Select all that apply')).toBeTruthy(); + + fireEvent.click(optionButton('Alpha')); + expect(submit.disabled).toBe(false); + fireEvent.click(optionButton('Beta')); + fireEvent.click(optionButton('Alpha')); + fireEvent.click(optionButton('Gamma')); + + expect(screen.getByText('Pick several')).toBeTruthy(); + expect(respondQuestion).not.toHaveBeenCalled(); + expect(within(optionButton('Alpha')).queryByText('✓')).toBeNull(); + expect(within(optionButton('Beta')).getByText('✓')).toBeTruthy(); + expect(within(optionButton('Gamma')).getByText('✓')).toBeTruthy(); + + fireEvent.click(submit); + await waitFor(() => { + expect(respondQuestion).toHaveBeenCalledWith({ 'Pick several': 'Beta, Gamma' }); + }); + }); + + it('preserves and edits multi-select answers after going back', async () => { + renderQuestion([ + makeQuestion('First multi', ['Alpha', 'Beta'], true), + makeQuestion('Second single', ['Yes', 'No']), + ]); + + fireEvent.click(optionButton('Alpha')); + fireEvent.click(optionButton('Beta')); + expect(screen.getByText('First multi')).toBeTruthy(); + expect(screen.queryByText('Second single')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(within(optionButton('Alpha')).getByText('✓')).toBeTruthy(); + expect(within(optionButton('Beta')).getByText('✓')).toBeTruthy(); + + fireEvent.click(optionButton('Alpha')); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + fireEvent.click(optionButton('No')); + + await waitFor(() => { + expect(respondQuestion).toHaveBeenCalledWith({ + 'First multi': 'Beta', + 'Second single': 'No', + }); + }); + }); + + it('trims and submits a custom single-select answer with Enter', async () => { + renderQuestion([makeQuestion('Custom single', ['Alpha'])]); + fireEvent.click(customResponseButton()); + + const input = customInput(); + const send = screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement; + expect(send.disabled).toBe(true); + + fireEvent.change(input, { target: { value: ' ' } }); + expect(send.disabled).toBe(true); + + fireEvent.change(input, { target: { value: ' My answer ' } }); + expect(send.disabled).toBe(false); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(respondQuestion).toHaveBeenCalledWith({ 'Custom single': 'My answer' }); + }); + }); + + it('adds, deduplicates, and removes custom multi-select answers', async () => { + renderQuestion([makeQuestion('Custom multi', ['Alpha'], true)]); + + fireEvent.click(customResponseButton()); + fireEvent.change(customInput(), { target: { value: ' Extra ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => { + expect(screen.queryByPlaceholderText(/Enter your response/)).toBeNull(); + }); + expect(optionButton('Extra')).toBeTruthy(); + + fireEvent.click(customResponseButton()); + fireEvent.change(customInput(), { target: { value: 'Extra' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => { + expect(screen.queryByPlaceholderText(/Enter your response/)).toBeNull(); + }); + expect(screen.getAllByText('Extra')).toHaveLength(1); + + fireEvent.click(customResponseButton()); + fireEvent.change(customInput(), { target: { value: 'Alpha' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => { + expect(screen.queryByPlaceholderText(/Enter your response/)).toBeNull(); + }); + expect(screen.getAllByText('Alpha')).toHaveLength(1); + expect(within(optionButton('Alpha')).getByText('✓')).toBeTruthy(); + + fireEvent.click(optionButton('Extra')); + expect(screen.queryByText('Extra')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => { + expect(respondQuestion).toHaveBeenCalledWith({ 'Custom multi': 'Alpha' }); + }); + }); + + it('ignores Enter during IME composition and closes custom input with Escape', async () => { + renderQuestion([makeQuestion('Keyboard', ['Alpha'])]); + fireEvent.click(customResponseButton()); + + const input = customInput(); + fireEvent.change(input, { target: { value: ' Draft answer ' } }); + fireEvent.keyDown(input, { key: 'Enter', isComposing: true }); + expect(respondQuestion).not.toHaveBeenCalled(); + expect(customInput().value).toBe(' Draft answer '); + + fireEvent.keyDown(input, { key: 'Escape' }); + expect(screen.queryByPlaceholderText(/Enter your response/)).toBeNull(); + + fireEvent.click(customResponseButton()); + expect(customInput().value).toBe(' Draft answer '); + fireEvent.keyDown(customInput(), { key: 'Enter' }); + + await waitFor(() => { + expect(respondQuestion).toHaveBeenCalledWith({ Keyboard: 'Draft answer' }); + }); + }); + + it('disables answers and Back while a final single-select submission is pending', async () => { + const response = createDeferred(); + respondQuestion.mockReturnValue(response.promise); + renderQuestion([ + makeQuestion('First question', ['Alpha']), + makeQuestion('Final question', ['Gamma', 'Delta']), + ]); + + fireEvent.click(optionButton('Alpha')); + fireEvent.click(optionButton('Delta')); + + const back = screen.getByRole('button', { name: 'Back' }) as HTMLButtonElement; + expect(back.disabled).toBe(true); + expect(optionButton('Gamma').disabled).toBe(true); + expect(optionButton('Delta').disabled).toBe(true); + expect(customResponseButton().disabled).toBe(true); + + fireEvent.click(back); + fireEvent.click(optionButton('Gamma')); + expect(screen.getByText('Final question')).toBeTruthy(); + expect(respondQuestion).toHaveBeenCalledTimes(1); + + await act(async () => { + response.resolve(); + }); + expect(back.disabled).toBe(false); + expect(optionButton('Gamma').disabled).toBe(false); + expect(customResponseButton().disabled).toBe(false); + }); + + it('disables multi-select and custom controls while a final submission is pending', async () => { + const response = createDeferred(); + respondQuestion.mockReturnValue(response.promise); + renderQuestion([ + makeQuestion('First question', ['One']), + makeQuestion('Final multi', ['Alpha', 'Beta'], true), + ]); + + fireEvent.click(optionButton('One')); + fireEvent.click(optionButton('Alpha')); + fireEvent.click(customResponseButton()); + fireEvent.change(customInput(), { target: { value: 'Draft' } }); + const back = screen.getByRole('button', { name: 'Back' }) as HTMLButtonElement; + const send = screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement; + const submit = screen.getByRole('button', { name: 'Submit' }) as HTMLButtonElement; + fireEvent.click(submit); + + expect(back.disabled).toBe(true); + expect(optionButton('Alpha').disabled).toBe(true); + expect(optionButton('Beta').disabled).toBe(true); + expect(customInput().disabled).toBe(true); + expect(send.disabled).toBe(true); + expect(submit.disabled).toBe(true); + + fireEvent.click(submit); + expect(respondQuestion).toHaveBeenCalledTimes(1); + + await act(async () => { + response.resolve(); + }); + expect(back.disabled).toBe(false); + expect(optionButton('Alpha').disabled).toBe(false); + expect(customInput().disabled).toBe(false); + expect(send.disabled).toBe(false); + expect(submit.disabled).toBe(false); + }); + + it('resets navigation, answers, and custom drafts when a new question request arrives', async () => { + renderQuestion([ + makeQuestion('Old first', ['One']), + makeQuestion('Old second', ['Two']), + ], 'old-request'); + fireEvent.click(optionButton('One')); + expect(screen.getByText('Old second')).toBeTruthy(); + fireEvent.click(customResponseButton()); + fireEvent.change(customInput(), { target: { value: 'Old draft' } }); + + act(() => { + setPendingQuestion([makeQuestion('New first', ['Three'])], 'new-request'); + }); + expect(screen.getByText('New first')).toBeTruthy(); + expect(screen.queryByText('Old second')).toBeNull(); + expect(screen.queryByText('Question 1 of 1')).toBeNull(); + fireEvent.click(customResponseButton()); + expect(customInput().value).toBe(''); + fireEvent.keyDown(customInput(), { key: 'Escape' }); + + fireEvent.click(optionButton('Three')); + await waitFor(() => { + expect(respondQuestion).toHaveBeenCalledExactlyOnceWith({ 'New first': 'Three' }); + }); + }); +}); diff --git a/apps/vscode/tsdown.config.ts b/apps/vscode/tsdown.config.ts index ba99a45db..199c94353 100644 --- a/apps/vscode/tsdown.config.ts +++ b/apps/vscode/tsdown.config.ts @@ -22,7 +22,6 @@ export default defineConfig({ '@pymodel/pythinker-code-sdk': resolve(root, '../../packages/node-sdk/src/index.ts'), '@pymodel/pythinker-telemetry': resolve(root, '../../packages/telemetry/src/index.ts'), '@pymodel/migration-legacy': resolve(root, '../../packages/migration-legacy/src/index.ts'), - '@pymodel/pyaos': resolve(root, '../../packages/pyaos/src/index.ts'), '@pymodel/pythinker-code-oauth': resolve(root, '../../packages/oauth/src/index.ts'), '@pymodel/kosong': resolve(root, '../../packages/kosong/src/index.ts'), }, diff --git a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx index 45e48fec2..d9c46cfc7 100644 --- a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx +++ b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx @@ -4,51 +4,76 @@ import { cn } from "@/lib/utils"; export function QuestionDialog() { const { pendingQuestion, respondQuestion } = useChatStore(); - const [customInput, setCustomInput] = useState(""); + const [customDrafts, setCustomDrafts] = useState>({}); const [showCustom, setShowCustom] = useState(false); const [selectedIndex, setSelectedIndex] = useState(1); const [questionIndex, setQuestionIndex] = useState(0); - const [answers, setAnswers] = useState>({}); - const [multiSelected, setMultiSelected] = useState([]); + const [answers, setAnswers] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); const questions = pendingQuestion?.questions ?? []; const question = questions[questionIndex]; const isMultiSelect = question?.multi_select === true; const isLastQuestion = questionIndex + 1 >= questions.length; + const currentAnswer = question ? answers[question.question] : undefined; + const selectedValues = Array.isArray(currentAnswer) ? currentAnswer : typeof currentAnswer === "string" ? [currentAnswer] : []; + const customInput = customDrafts[questionIndex] ?? ""; useEffect(() => { if (pendingQuestion) { setShowCustom(false); - setCustomInput(""); + setCustomDrafts({}); setSelectedIndex(1); setQuestionIndex(0); setAnswers({}); - setMultiSelected([]); + setIsSubmitting(false); } }, [pendingQuestion?.id]); if (!pendingQuestion || !question) return null; + const updateCustomInput = (value: string) => { + setCustomDrafts((currentDrafts) => ({ ...currentDrafts, [questionIndex]: value })); + }; + // Step through the questions one by one; submit all answers after the last. - const handleAnswer = async (answer: string) => { + const handleAnswer = async (answer: string | string[]) => { const nextAnswers = { ...answers, [question.question]: answer }; if (!isLastQuestion) { setAnswers(nextAnswers); setQuestionIndex(questionIndex + 1); setShowCustom(false); - setCustomInput(""); setSelectedIndex(1); - setMultiSelected([]); } else { - await respondQuestion(nextAnswers); + setIsSubmitting(true); + try { + await respondQuestion( + Object.fromEntries(Object.entries(nextAnswers).map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : value])), + ); + } finally { + setIsSubmitting(false); + } } }; + const handleBack = () => { + setQuestionIndex(questionIndex - 1); + setShowCustom(false); + setSelectedIndex(1); + }; + const handleSelect = async (optionLabel: string) => { if (isMultiSelect) { - setMultiSelected((prev) => - prev.includes(optionLabel) ? prev.filter((value) => value !== optionLabel) : [...prev, optionLabel], - ); + setAnswers((currentAnswers) => { + const answer = currentAnswers[question.question]; + const values = Array.isArray(answer) ? answer : []; + return { + ...currentAnswers, + [question.question]: values.includes(optionLabel) + ? values.filter((value) => value !== optionLabel) + : [...values, optionLabel], + }; + }); return; } await handleAnswer(optionLabel); @@ -58,24 +83,32 @@ export function QuestionDialog() { const value = customInput.trim(); if (!value) return; if (isMultiSelect) { - setMultiSelected((prev) => (prev.includes(value) ? prev : [...prev, value])); - setCustomInput(""); + if (!selectedValues.includes(value)) await handleSelect(value); + updateCustomInput(""); setShowCustom(false); return; } await handleAnswer(value); + updateCustomInput(""); }; const options = question.options || []; const customIndex = options.length + 1; - const customValues = multiSelected.filter((value) => !options.some((option) => option.label === value)); + const customValues = selectedValues.filter((value) => !options.some((option) => option.label === value)); return (
{questions.length > 1 && ( -
- Question {questionIndex + 1} of {questions.length} +
+ + Question {questionIndex + 1} of {questions.length} + + {questionIndex > 0 && ( + + )}
)} {question.header &&
{question.header}
} @@ -83,7 +116,7 @@ export function QuestionDialog() { {isMultiSelect &&
Select all that apply
}
{options.map((option, idx) => { - const isChecked = isMultiSelect && multiSelected.includes(option.label); + const isChecked = selectedValues.includes(option.label); const isHighlighted = selectedIndex === idx + 1; return ( )} + {!isMultiSelect && !isLastQuestion && currentAnswer !== undefined && ( + + )} {isMultiSelect && (