diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 29906ae56..9a0ae43f4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -167,6 +167,54 @@ - Fixed the transcript keeping finalized assistant blocks in the live compose walk after their rows entered native terminal scrollback, making each stream tick's `TranscriptContainer.render` depth-linear in session length. Fully committed finalized blocks are now compacted out of the local frame regardless of post-finalize version tracking; a later mutation no longer recommits on ordinary frames (no duplication) and rehydrates on the next destructive full replay (no loss). Compose cost for a live tail tick is now flat as depth grows (`bench/transcript-compose.bench.ts`: ratio(N5000/N500) 2.30 → 0.90) ([#5930](https://github.com/can1357/oh-my-pi/issues/5930)). - Fixed `/quit` and `/exit` hanging during interactive shutdown by making the mnemopi dispose path retain the current session and flush in-flight extractions without sleeping the bank; the `/memory enqueue` path and end-of-session backend enqueue still perform full cross-session consolidation. ([#3641](https://github.com/can1357/oh-my-pi/issues/3641)) +### Fixed + +- Fixed interactive bash shortcut `cd` commands leaving the OMP session and status-line working directory unchanged. + +## [17.0.3] - 2026-07-17 + +### Changed + +- `omp usage` and the in-session `/usage` view now show the Anthropic organization next to the account for org-scoped credentials (with `--redact` masking applied per part in the CLI, falling back to the org id when no display name is available), attribute "no usage data" rows per organization, and match the "in use by this session" marker by organization so only the active subscription is flagged. The OAuth login success message names the account and organization that was stored — a login landing on an unintended subscription is visible immediately. +- `/logout` labels Anthropic accounts with their organization and marks only the credential of the active organization as active; `omp token --list` shows the organization next to each account. Two subscriptions sharing one email are distinguishable when selecting which to remove or mint a token for. +- `omp auth-broker migrate --from-local` dedupes Anthropic OAuth identities per organization, so a Team seat already on the broker no longer blocks uploading the personal plan under the same email. +- The status line invalidates its cached usage when the session rotates to a different Anthropic organization (previously the old subscription's quota could linger for the cache TTL), and `omp auth-gateway check` labels each credential with its organization so a failing row says which subscription needs re-login. +- `omp usage` "no usage data" attribution is org-decisive whenever either the stored account or a report carries an organization: an org-less legacy credential whose own fetch failed is no longer hidden by an org-attributed sibling report sharing the same email. +- Active-account matching for `/usage`, `/logout`, and `omp token --list` now treats a shared organization as a qualifier rather than a match: two Anthropic Team seats in one org (same org id, per-user pools) no longer flag each other's rows or reports as "in use by this session" — the base identity (account/email/project) is still required, with org-only sessions matching on the org alone. +- `omp usage` "no usage data" coverage now requires the member's own identity within a shared organization: a sibling Team member's same-org report no longer counts as coverage for an account whose own report is missing, while an org-only account remains covered by any same-org report. +- `omp auth-broker migrate --from-local` reruns now recognize an already-migrated org-only Anthropic row (login recovered neither email nor account) by its organization id instead of re-uploading it, which could overwrite the broker's newer refresh token with the stale local one. +- Updated tangential agent forks to ignore parent session history and focus exclusively on the new request +- Hardened `/tan` fork isolation: the clone's inherited todo list is cleared at fork (parent todo reminders no longer drag the tan back onto the parent's task), the fork notice warns that the parent is concurrently editing the same working directory, and the notice is re-injected after each compaction so the fork boundary survives summarization +- Added visual markers in the transcript for elided tool calls that have no corresponding result +- Updated status event log to prioritize the most recent entries in the display window +- Updated the snapcompact shape preview transcript to use the compact scope format shown to models during compaction. + +### Fixed + +- Fixed `xd://` mount notices triggering unsolicited model turns by deferring hidden notices until the next user prompt. +- Fixed `xd://` device tools appearing in the direct tool inventory and prompting invalid function calls ([#5797](https://github.com/can1357/oh-my-pi/issues/5797)). +- Fixed `history://` read selectors being treated as part of the agent id instead of paging the transcript ([#5806](https://github.com/can1357/oh-my-pi/issues/5806)). +- Narrowed the `history://` contract in the system prompt to match the implementation: it serves registered agents process-wide plus persisted subagents discoverable from their artifact trees, but does not discover unregistered top-level sessions solely from persisted session files ([#5839](https://github.com/can1357/oh-my-pi/issues/5839)). +- Fixed expanded `!` bash and `eval` output keeping a stale `… N more lines (ctrl+o to expand)` footer after Ctrl+O revealed every line ([#5842](https://github.com/can1357/oh-my-pi/issues/5842)). +- Fixed MCP reauthentication continuing to an authorization URL without `client_id` after dynamic client registration fails; the registration error now blocks the flow with the provider response details ([#5852](https://github.com/can1357/oh-my-pi/issues/5852)). +- Fixed collapsed todo views hiding the in-progress task in large phases. Both the transient `Todo` tool result and the sticky `Todos` HUD now share one walking-viewport policy: completed/abandoned tasks are omitted, every active task (the in-progress one, or a pending task a live subagent is executing) is pulled to the head in todo order, remaining rows fill with the following pending tasks, and an explicit `… N more active todos` summary is shown when active work alone exceeds the preview cap ([#5873](https://github.com/can1357/oh-my-pi/issues/5873)). +- Fixed legacy provider extensions failing to load when they use the historical synchronous auth-storage surface ([#5879](https://github.com/can1357/oh-my-pi/issues/5879)). +- Fixed orphaned detached MCP stdio server process trees surviving session dispose by escalating stdin-EOF → group SIGTERM → group SIGKILL on close() (#5578) +- Fixed `/new` starting an unsolicited old-context provider turn when a hidden steer (e.g. an `xd://` mount notice) was queued: the session transition is now an atomic boundary, so a queued steer/follow-up can no longer auto-resume against the pre-`/new` context while the session is disconnected mid-transition. `/compact` still resumes a steer/follow-up that arrives while it runs, draining the queue once it reconnects ([#5800](https://github.com/can1357/oh-my-pi/issues/5800)). +- Fixed signed thinking-only Claude stops being discarded and retried as empty responses ([#5881](https://github.com/can1357/oh-my-pi/issues/5881)). +- Fixed repeated URL reads and URL-backed searches returning stale same-session responses instead of refetching the resource ([#5803](https://github.com/can1357/oh-my-pi/issues/5803)). +- Fixed `read`/`write` not recognizing ZIP-based `.jar`/`.war`/`.ear`/`.apk` files as archives, so `read lib.jar:META-INF/MANIFEST.MF` failed with path-not-found ([#5808](https://github.com/can1357/oh-my-pi/issues/5808)). +- Fixed legacy binary `.doc`/`.ppt`/`.xls`/`.rtf` being advertised as convertible in `read`, `fetch`, and CLI `@file` handling despite having no markit converter, which surfaced an `Unsupported format` error instead of falling through to normal file handling ([#5808](https://github.com/can1357/oh-my-pi/issues/5808)). +- Fixed Kimi Code transport selection to follow live per-model protocol metadata by default while preserving explicit OpenAI and Anthropic overrides ([#5893](https://github.com/can1357/oh-my-pi/issues/5893)). +- Fixed repeated edit-tool rejections from local models by recovering comma-separated ranges and malformed trailers, while clarifying canonical string input and `.=` syntax ([#5805](https://github.com/can1357/oh-my-pi/issues/5805)). +- Fixed LSP diagnostics and edit-time diagnostics writethrough for pull-only servers that advertise `textDocument/diagnostic` statically or through dynamic registration ([#5825](https://github.com/can1357/oh-my-pi/issues/5825)). +- Fixed local `!` command output concatenating carriage-return progress updates by preserving them as readable line boundaries ([#5845](https://github.com/can1357/oh-my-pi/issues/5845)). +- Fixed `hub`/`irc` peer discovery after process-crash resume by restoring persisted subagents as parked peers before listing the roster ([#5864](https://github.com/can1357/oh-my-pi/issues/5864)). + +### Removed + +- Removed the unreliable Bing and Yahoo HTML-scraping web search providers + ## [17.0.2] - 2026-07-17 ### Added diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index c77108bcf..1da0488f5 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -150,6 +150,53 @@ function isBashShell(shell: string): boolean { return basename.includes("bash"); } +const UNSUPPORTED_UNQUOTED_CD_CHARS = "\\$`;&|<>(){}*?[]!#\"'"; + +function hasUnsupportedUnquotedCdSyntax(value: string): boolean { + for (const char of value) { + if (/\s/.test(char) || UNSUPPORTED_UNQUOTED_CD_CHARS.includes(char)) return true; + } + return false; +} + +export function isPersistentShellCdCommand(command: string): boolean { + if (/[\r\n]/.test(command)) return false; + + const trimmed = command.trim(); + if (trimmed === "cd") return true; + if (!trimmed.startsWith("cd") || !/[ \t]/.test(trimmed[2] ?? "")) return false; + + let rest = trimmed.slice(2).trim(); + if (rest === "" || rest === "--") return true; + + let hasOptionTerminator = false; + if (/^--[ \t]/.test(rest)) { + hasOptionTerminator = true; + rest = rest.slice(2).trimStart(); + } + if (rest === "") return true; + + const quote = rest[0]; + let target: string; + let quoted = false; + if (quote === `"` || quote === "'") { + if (rest.length < 2 || rest[rest.length - 1] !== quote) return false; + target = rest.slice(1, -1); + if (target.includes(quote)) return false; + if (quote === `"` && /[\\$`\r\n]/.test(target)) return false; + quoted = true; + } else { + if (hasUnsupportedUnquotedCdSyntax(rest)) return false; + target = rest; + } + + if (target === "") return false; + if (/^[+-]\d+$/.test(target)) return false; + if (!hasOptionTerminator && target.startsWith("-") && target !== "-") return false; + if (!quoted && target.startsWith("~") && target !== "~" && !target.startsWith("~/")) return false; + return true; +} + function needsInteractiveShellArg(shell: string): boolean { const basename = shellBasename(shell); return basename.includes("zsh") || basename.includes("fish"); @@ -236,8 +283,9 @@ export async function executeBash(command: string, options?: BashExecutorOptions // Apply command prefix if configured const prefixedCommand = prefix ? `${prefix} ${command}` : command; + const runCdInPersistentShell = options?.useUserShell === true && !prefix && isPersistentShellCdCommand(command); const finalCommand = - options?.useUserShell === true && !bashShell + options?.useUserShell === true && !bashShell && !runCdInPersistentShell ? buildUserShellCommand(shell, args, prefixedCommand) : prefixedCommand; diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 6d21be4de..9b7f17b06 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -13,6 +13,7 @@ import { import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui"; import { formatDuration, Snowflake, sanitizeText } from "@oh-my-pi/pi-utils"; import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode"; +import { type BashResult, isPersistentShellCdCommand } from "../../exec/bash-executor"; import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share"; import { shareSession } from "../../export/share"; import type { CompactOptions } from "../../extensibility/extensions/types"; @@ -1100,6 +1101,12 @@ export class CommandController { async handleBashCommand(command: string, excludeFromContext = false): Promise { const isDeferred = this.ctx.session.isStreaming; + const shouldPersistCwd = isPersistentShellCdCommand(command); + if (isDeferred && shouldPersistCwd) { + this.ctx.showWarning("Wait for the current response to finish or abort it before changing directories."); + return; + } + this.ctx.bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext); if (isDeferred) { @@ -1120,7 +1127,6 @@ export class CommandController { }, { excludeFromContext, useUserShell: true }, ); - if (this.ctx.bashComponent) { const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get(); this.ctx.bashComponent.setComplete(result.exitCode, result.cancelled, { @@ -1128,6 +1134,15 @@ export class CommandController { truncation: meta?.truncation, }); } + try { + if (shouldPersistCwd) await this.#applyBashResultCwd(result); + } catch (error) { + this.ctx.showError( + `Bash command completed, but OMP failed to update its working directory: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + } } catch (error) { if (this.ctx.bashComponent) { this.ctx.bashComponent.setComplete(undefined, false); @@ -1139,6 +1154,31 @@ export class CommandController { this.ctx.ui.requestRender(); } + async #moveInteractiveCwd(resolvedPath: string): Promise { + await this.ctx.sessionManager.moveTo(resolvedPath); + await this.ctx.applyCwdChange(resolvedPath); + this.ctx.updateEditorBorderColor(); + await this.ctx.reloadTodos(); + } + + async #applyBashResultCwd(result: BashResult): Promise { + if (result.cancelled || result.exitCode !== 0 || !result.workingDir) return; + if (!path.isAbsolute(result.workingDir)) return; + + const resolvedPath = path.resolve(result.workingDir); + if (resolvedPath === path.resolve(this.ctx.sessionManager.getCwd())) return; + + let isDirectory = false; + try { + isDirectory = (await fs.stat(resolvedPath)).isDirectory(); + } catch { + isDirectory = false; + } + if (!isDirectory) return; + + await this.#moveInteractiveCwd(resolvedPath); + } + async handlePythonCommand(code: string, excludeFromContext = false): Promise { const isDeferred = this.ctx.session.isStreaming; this.ctx.pythonComponent = new EvalExecutionComponent(code, this.ctx.ui, excludeFromContext); diff --git a/packages/coding-agent/test/bash-executor.test.ts b/packages/coding-agent/test/bash-executor.test.ts index 9e3096ba8..31fca9b76 100644 --- a/packages/coding-agent/test/bash-executor.test.ts +++ b/packages/coding-agent/test/bash-executor.test.ts @@ -3,7 +3,11 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { resetSettingsForTest, Settings, type ShellMinimizerSettings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { buildMinimizerOptions, executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor"; +import { + buildMinimizerOptions, + executeBash, + isPersistentShellCdCommand, +} from "@oh-my-pi/pi-coding-agent/exec/bash-executor"; import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output"; import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot"; import type { Shell, ShellRunResult } from "@oh-my-pi/pi-natives"; @@ -126,6 +130,34 @@ describe("executeBash", () => { legacyFilters: true, }); }); + + it.each([ + ["cd", true], + [" cd child ", true], + ["cd\tchild", true], + ["cd -", true], + ["cd --", true], + ["cd -- -P", true], + ['cd "#note"', true], + ['cd "two words"', true], + ["cd '~/literal'", true], + ["cd +1", false], + ['cd "+1"', false], + ["cd -- -1", false], + ["cd -- '+2'", false], + ["cd\npwd", false], + ["cd\rpwd", false], + ["cd -P", false], + ["cd -L /tmp", false], + ["cd #note", false], + ["cd child && pwd", false], + ["cd two words", false], + ['cd ""', false], + ["cd ~other", false], + ["echo cd child", false], + ] as const)("classifies persistent-shell cd routing for %j", (command, expected) => { + expect(isPersistentShellCdCommand(command)).toBe(expected); + }); it("returns non-zero exit codes without cancellation", async () => { const result = await executeBash("exit 7", { cwd: tempDir, timeout: 5000 }); expect(result.exitCode).toBe(7); @@ -227,6 +259,88 @@ exit 64 } }); + it("persists cd, bare cd, and cd - when shortcut commands use a non-bash user shell", async () => { + if (process.platform === "win32") return; + + const shellDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-cd-shellpath-")); + const marker = path.join(shellDir, "fake-shell-ran"); + const fakeShell = path.join(shellDir, "fake-shell"); + const childDir = path.join(tempDir, "child"); + fs.mkdirSync(childDir); + fs.writeFileSync( + fakeShell, + `#!/bin/sh +printf '%s\\n' "$*" > ${shellQuote(marker)} +while [ "$#" -gt 0 ]; do + if [ "$1" = "-c" ]; then + shift + exec /bin/sh -c "$1" + fi + shift +done +exit 64 +`, + ); + fs.chmodSync(fakeShell, 0o755); + Settings.instance.set("shellPath", fakeShell); + vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({ + shell: fakeShell, + args: ["-l", "-c"], + env: { + PATH: Bun.env.PATH ?? "", + HOME: tempDir, + }, + prefix: undefined, + }); + + try { + const sessionKey = `persistent-cd-${Date.now()}`; + const moved = await executeBash("cd child", { + cwd: tempDir, + timeout: 5000, + sessionKey, + useUserShell: true, + }); + + expect(moved.exitCode).toBe(0); + expect(moved.workingDir).toBe(childDir); + expect(fs.existsSync(marker)).toBe(false); + + const home = await executeBash("cd", { + cwd: childDir, + timeout: 5000, + sessionKey, + useUserShell: true, + }); + + expect(home.exitCode).toBe(0); + expect(home.workingDir).toBe(tempDir); + expect(fs.existsSync(marker)).toBe(false); + + const returned = await executeBash("cd -", { + cwd: tempDir, + timeout: 5000, + sessionKey, + useUserShell: true, + }); + + expect(returned.exitCode).toBe(0); + expect(returned.workingDir).toBe(childDir); + expect(fs.existsSync(marker)).toBe(false); + + const pwd = await executeBash("pwd", { + cwd: childDir, + timeout: 5000, + sessionKey, + useUserShell: true, + }); + expect(pwd.output.trim()).toBe(childDir); + expect(fs.existsSync(marker)).toBe(true); + } finally { + removeSyncWithRetries(shellDir); + } + }); + it("uses executable SHELL for user-shell shortcut commands", async () => { if (process.platform === "win32") { return; diff --git a/packages/coding-agent/test/modes/controllers/bash-command.test.ts b/packages/coding-agent/test/modes/controllers/bash-command.test.ts index fd2bd0e1f..12d17ac4e 100644 --- a/packages/coding-agent/test/modes/controllers/bash-command.test.ts +++ b/packages/coding-agent/test/modes/controllers/bash-command.test.ts @@ -1,4 +1,8 @@ import { beforeAll, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { BashExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/bash-execution"; import { CommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/command-controller"; import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; @@ -12,6 +16,51 @@ function createContainer() { }; } +function createCwdContext(sourceDir: string, isStreaming = false) { + const state = { cwd: sourceDir, executedCwds: [] as string[] }; + const executeBash = vi.fn(async (command: string) => { + state.executedCwds.push(state.cwd); + return { + output: command === "pwd" ? `${state.cwd}\n` : "ok", + exitCode: 0, + cancelled: false, + truncated: false, + totalLines: 1, + totalBytes: state.cwd.length, + outputLines: 1, + outputBytes: state.cwd.length, + workingDir: state.cwd, + }; + }); + const pendingMessagesContainer = createContainer(); + const present = vi.fn(); + const ctx = { + session: { + isStreaming, + executeBash, + }, + sessionManager: { + getCwd: () => state.cwd, + moveTo: vi.fn(async (cwd: string) => { + state.cwd = cwd; + }), + }, + chatContainer: createContainer(), + pendingMessagesContainer, + pendingBashComponents: [], + ui: { requestRender: vi.fn(), requestComponentRender: vi.fn() }, + present, + showError: vi.fn(), + showWarning: vi.fn(), + applyCwdChange: vi.fn(async (cwd: string) => { + expect(state.cwd).toBe(cwd); + }), + updateEditorBorderColor: vi.fn(), + reloadTodos: vi.fn(async () => {}), + } as unknown as InteractiveModeContext; + return { ctx, executeBash, pendingMessagesContainer, present, state }; +} + describe("bash shortcut command", () => { beforeAll(async () => { const theme = await getThemeByName("dark"); @@ -35,12 +84,18 @@ describe("bash shortcut command", () => { isStreaming: false, executeBash, }, + sessionManager: { + getCwd: () => "/tmp", + }, chatContainer: createContainer(), pendingMessagesContainer: createContainer(), pendingBashComponents: [], ui: { requestRender: vi.fn(), requestComponentRender: vi.fn() }, present: vi.fn(), showError: vi.fn(), + applyCwdChange: vi.fn(async () => {}), + updateEditorBorderColor: vi.fn(), + reloadTodos: vi.fn(async () => {}), } as unknown as InteractiveModeContext; const controller = new CommandController(ctx); @@ -51,4 +106,186 @@ describe("bash shortcut command", () => { useUserShell: true, }); }); + + it("persists standalone and bare cd before the next user-shell command", async () => { + const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-bash-cd-source-")); + const childDir = path.join(sourceDir, "child"); + await fs.mkdir(childDir); + try { + const { ctx, executeBash, state } = createCwdContext(sourceDir); + executeBash.mockImplementationOnce(async () => { + state.executedCwds.push(state.cwd); + return { + output: "", + exitCode: 0, + cancelled: false, + truncated: false, + totalLines: 0, + totalBytes: 0, + outputLines: 0, + outputBytes: 0, + workingDir: childDir, + }; + }); + executeBash.mockImplementationOnce(async () => { + state.executedCwds.push(state.cwd); + return { + output: "", + exitCode: 0, + cancelled: false, + truncated: false, + totalLines: 0, + totalBytes: 0, + outputLines: 0, + outputBytes: 0, + workingDir: sourceDir, + }; + }); + const controller = new CommandController(ctx); + + await controller.handleBashCommand("cd child"); + await controller.handleBashCommand("cd"); + await controller.handleBashCommand("pwd"); + + expect(state.cwd).toBe(sourceDir); + expect(state.executedCwds).toEqual([sourceDir, childDir, sourceDir]); + expect(executeBash).toHaveBeenCalledTimes(3); + expect(executeBash).toHaveBeenNthCalledWith(1, "cd child", expect.any(Function), { + excludeFromContext: false, + useUserShell: true, + }); + expect(executeBash).toHaveBeenNthCalledWith(2, "cd", expect.any(Function), { + excludeFromContext: false, + useUserShell: true, + }); + expect(ctx.applyCwdChange).toHaveBeenNthCalledWith(1, childDir); + expect(ctx.applyCwdChange).toHaveBeenNthCalledWith(2, sourceDir); + expect(ctx.updateEditorBorderColor).toHaveBeenCalledTimes(2); + expect(ctx.reloadTodos).toHaveBeenCalledTimes(2); + expect(ctx.showError).not.toHaveBeenCalled(); + } finally { + await fs.rm(sourceDir, { recursive: true, force: true }); + } + }); + + it("does not adopt cwd from a non-cd bash command", async () => { + const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-bash-cwd-sync-")); + const childDir = path.join(sourceDir, "child"); + await fs.mkdir(childDir); + try { + const { ctx, executeBash, state } = createCwdContext(sourceDir); + executeBash.mockImplementationOnce(async () => { + state.executedCwds.push(state.cwd); + return { + output: "", + exitCode: 0, + cancelled: false, + truncated: false, + totalLines: 0, + totalBytes: 0, + outputLines: 0, + outputBytes: 0, + workingDir: childDir, + }; + }); + const controller = new CommandController(ctx); + + await controller.handleBashCommand("pushd child >/dev/null"); + + expect(state.cwd).toBe(sourceDir); + expect(state.executedCwds).toEqual([sourceDir]); + expect(executeBash).toHaveBeenCalledTimes(1); + expect(ctx.applyCwdChange).not.toHaveBeenCalled(); + expect(ctx.updateEditorBorderColor).not.toHaveBeenCalled(); + expect(ctx.reloadTodos).not.toHaveBeenCalled(); + expect(ctx.showWarning).not.toHaveBeenCalled(); + expect(ctx.showError).not.toHaveBeenCalled(); + } finally { + await fs.rm(sourceDir, { recursive: true, force: true }); + } + }); + + it("rejects simple cd while streaming before queuing a bash block", async () => { + const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-bash-cd-streaming-")); + try { + const { ctx, executeBash, pendingMessagesContainer, present, state } = createCwdContext(sourceDir, true); + const controller = new CommandController(ctx); + + await controller.handleBashCommand("cd child"); + + expect(state.cwd).toBe(sourceDir); + expect(executeBash).not.toHaveBeenCalled(); + expect(present).not.toHaveBeenCalled(); + expect(pendingMessagesContainer.children).toHaveLength(0); + expect(ctx.pendingBashComponents).toHaveLength(0); + expect(ctx.showWarning).toHaveBeenCalledWith(expect.stringContaining("response")); + } finally { + await fs.rm(sourceDir, { recursive: true, force: true }); + } + }); + + it("does not adopt cwd or warn for a non-cd command while streaming", async () => { + const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-bash-cwd-deferred-")); + const childDir = path.join(sourceDir, "child"); + await fs.mkdir(childDir); + try { + const { ctx, executeBash, pendingMessagesContainer, state } = createCwdContext(sourceDir, true); + executeBash.mockImplementationOnce(async () => ({ + output: "", + exitCode: 0, + cancelled: false, + truncated: false, + totalLines: 0, + totalBytes: 0, + outputLines: 0, + outputBytes: 0, + workingDir: childDir, + })); + const controller = new CommandController(ctx); + + await controller.handleBashCommand("pushd child >/dev/null"); + + expect(state.cwd).toBe(sourceDir); + expect(ctx.applyCwdChange).not.toHaveBeenCalled(); + expect(pendingMessagesContainer.children).toHaveLength(1); + expect(ctx.pendingBashComponents).toHaveLength(1); + expect(ctx.showWarning).not.toHaveBeenCalled(); + } finally { + await fs.rm(sourceDir, { recursive: true, force: true }); + } + }); + + it("finalizes successful output before reporting a standalone cd refresh failure", async () => { + const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-bash-cwd-refresh-error-")); + const childDir = path.join(sourceDir, "child"); + await fs.mkdir(childDir); + try { + const { ctx, executeBash, present, state } = createCwdContext(sourceDir); + executeBash.mockImplementationOnce(async () => ({ + output: "final output", + exitCode: 0, + cancelled: false, + truncated: false, + totalLines: 1, + totalBytes: 12, + outputLines: 1, + outputBytes: 12, + workingDir: childDir, + })); + ctx.applyCwdChange = vi.fn(async () => { + throw new Error("refresh failed"); + }); + const controller = new CommandController(ctx); + + await controller.handleBashCommand("cd child"); + + const component = present.mock.calls[0]?.[0]; + expect(component).toBeInstanceOf(BashExecutionComponent); + expect((component as BashExecutionComponent).getOutput()).toContain("final output"); + expect(state.cwd).toBe(childDir); + expect(ctx.showError).toHaveBeenCalledWith(expect.stringContaining("completed, but")); + } finally { + await fs.rm(sourceDir, { recursive: true, force: true }); + } + }); });