From 165603189b60231ed8b88274729471cee676a0c0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 16 Apr 2026 23:55:52 +0200 Subject: [PATCH] fix(coding-agent): parse quoted import paths and missing files --- .../src/core/agent-session-runtime.ts | 12 +- .../src/modes/interactive/interactive-mode.ts | 47 +++++- .../interactive-mode-import-command.test.ts | 144 ++++++++++++++++++ 3 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 packages/coding-agent/test/interactive-mode-import-command.test.ts diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index 58e1fd57..327140bd 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -33,6 +33,16 @@ export type CreateAgentSessionRuntimeFactory = (options: { sessionStartEvent?: SessionStartEvent; }) => Promise; +export class SessionImportFileNotFoundError extends Error { + readonly filePath: string; + + constructor(filePath: string) { + super(`File not found: ${filePath}`); + this.name = "SessionImportFileNotFoundError"; + this.filePath = filePath; + } +} + function extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { if (typeof content === "string") { return content; @@ -251,7 +261,7 @@ export class AgentSessionRuntime { async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { const resolvedPath = resolve(inputPath); if (!existsSync(resolvedPath)) { - throw new Error(`File not found: ${resolvedPath}`); + throw new SessionImportFileNotFoundError(resolvedPath); } const sessionDir = this.session.sessionManager.getSessionDir(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 94d7b414..c088c7f3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -47,7 +47,7 @@ import { VERSION, } from "../../config.js"; import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js"; -import type { AgentSessionRuntime } from "../../core/agent-session-runtime.js"; +import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js"; import type { ExtensionContext, ExtensionRunner, @@ -2278,12 +2278,12 @@ export class InteractiveMode { await this.handleModelCommand(searchTerm); return; } - if (text.startsWith("/export")) { + if (text === "/export" || text.startsWith("/export ")) { await this.handleExportCommand(text); this.editor.setText(""); return; } - if (text.startsWith("/import")) { + if (text === "/import" || text.startsWith("/import ")) { await this.handleImportCommand(text); this.editor.setText(""); return; @@ -4353,8 +4353,7 @@ export class InteractiveMode { } private async handleExportCommand(text: string): Promise { - const parts = text.split(/\s+/); - const outputPath = parts.length > 1 ? parts[1] : undefined; + const outputPath = this.getPathCommandArgument(text, "/export"); try { if (outputPath?.endsWith(".jsonl")) { @@ -4369,13 +4368,41 @@ export class InteractiveMode { } } + private getPathCommandArgument(text: string, command: "/export" | "/import"): string | undefined { + if (text === command) { + return undefined; + } + if (!text.startsWith(`${command} `)) { + return undefined; + } + + const argsString = text.slice(command.length + 1).trimStart(); + if (!argsString) { + return undefined; + } + + const firstChar = argsString[0]; + if (firstChar === '"' || firstChar === "'") { + const closingQuoteIndex = argsString.indexOf(firstChar, 1); + if (closingQuoteIndex < 0) { + return undefined; + } + return argsString.slice(1, closingQuoteIndex); + } + + const firstWhitespaceIndex = argsString.search(/\s/); + if (firstWhitespaceIndex < 0) { + return argsString; + } + return argsString.slice(0, firstWhitespaceIndex); + } + private async handleImportCommand(text: string): Promise { - const parts = text.split(/\s+/); - if (parts.length < 2 || !parts[1]) { + const inputPath = this.getPathCommandArgument(text, "/import"); + if (!inputPath) { this.showError("Usage: /import "); return; } - const inputPath = parts[1]; const confirmed = await this.showExtensionConfirm("Import session", `Replace current session with ${inputPath}?`); if (!confirmed) { @@ -4414,6 +4441,10 @@ export class InteractiveMode { this.showStatus(`Session imported from: ${inputPath}`); return; } + if (error instanceof SessionImportFileNotFoundError) { + this.showError(`Failed to import session: ${error.message}`); + return; + } await this.handleFatalRuntimeError("Failed to import session", error); } } diff --git a/packages/coding-agent/test/interactive-mode-import-command.test.ts b/packages/coding-agent/test/interactive-mode-import-command.test.ts new file mode 100644 index 00000000..85575ec0 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-import-command.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from "vitest"; +import { SessionImportFileNotFoundError } from "../src/core/agent-session-runtime.js"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; + +type PathCommand = "/export" | "/import"; + +type InteractiveModePrototype = { + getPathCommandArgument(this: unknown, text: string, command: PathCommand): string | undefined; + handleImportCommand(this: ImportCommandContext, text: string): Promise; +}; + +type ImportCommandContext = { + loadingAnimation?: { stop: () => void }; + statusContainer: { clear: () => void }; + runtimeHost: { importFromJsonl: (inputPath: string, cwdOverride?: string) => Promise<{ cancelled: boolean }> }; + showError: (message: string) => void; + showStatus: (message: string) => void; + showExtensionConfirm: (title: string, message: string) => Promise; + handleRuntimeSessionChange: () => Promise; + renderCurrentSessionState: () => void; + handleFatalRuntimeError: (prefix: string, error: unknown) => Promise; + promptForMissingSessionCwd: (error: unknown) => Promise; + getPathCommandArgument: (text: string, command: PathCommand) => string | undefined; +}; + +const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; + +describe("InteractiveMode /import parsing", () => { + it("strips quotes from /import path arguments", () => { + expect(interactiveModePrototype.getPathCommandArgument('/import "path/to/session.jsonl"', "/import")).toBe( + "path/to/session.jsonl", + ); + expect( + interactiveModePrototype.getPathCommandArgument('/import "path with spaces/session.jsonl"', "/import"), + ).toBe("path with spaces/session.jsonl"); + }); + + it("preserves apostrophes in unquoted /import path arguments", () => { + expect(interactiveModePrototype.getPathCommandArgument("/import john's/session.jsonl", "/import")).toBe( + "john's/session.jsonl", + ); + }); + + it("enforces command token boundaries", () => { + expect(interactiveModePrototype.getPathCommandArgument("/important /tmp/session.jsonl", "/import")).toBe( + undefined, + ); + expect(interactiveModePrototype.getPathCommandArgument("/exporter out.html", "/export")).toBe(undefined); + expect(interactiveModePrototype.getPathCommandArgument("/import /tmp/session.jsonl", "/import")).toBe( + "/tmp/session.jsonl", + ); + }); + + it("passes unquoted path to runtimeHost.importFromJsonl", async () => { + const importFromJsonl = vi.fn(async () => ({ cancelled: false })); + const showExtensionConfirm = vi.fn(async () => true); + const showStatus = vi.fn(); + const showError = vi.fn(); + + const context: ImportCommandContext = { + statusContainer: { clear: vi.fn() }, + runtimeHost: { importFromJsonl }, + showError, + showStatus, + showExtensionConfirm, + handleRuntimeSessionChange: vi.fn(async () => {}), + renderCurrentSessionState: vi.fn(), + handleFatalRuntimeError: vi.fn(async () => { + throw new Error("unexpected fatal error"); + }), + promptForMissingSessionCwd: vi.fn(async () => undefined), + getPathCommandArgument: interactiveModePrototype.getPathCommandArgument, + }; + + await interactiveModePrototype.handleImportCommand.call(context, '/import "path/to/session.jsonl"'); + + expect(showExtensionConfirm).toHaveBeenCalledWith( + "Import session", + "Replace current session with path/to/session.jsonl?", + ); + expect(importFromJsonl).toHaveBeenCalledWith("path/to/session.jsonl"); + expect(showError).not.toHaveBeenCalled(); + expect(showStatus).toHaveBeenCalledWith("Session imported from: path/to/session.jsonl"); + }); + + it("passes unquoted apostrophe path to runtimeHost.importFromJsonl unchanged", async () => { + const importFromJsonl = vi.fn(async () => ({ cancelled: false })); + const showExtensionConfirm = vi.fn(async () => true); + const showStatus = vi.fn(); + const showError = vi.fn(); + + const context: ImportCommandContext = { + statusContainer: { clear: vi.fn() }, + runtimeHost: { importFromJsonl }, + showError, + showStatus, + showExtensionConfirm, + handleRuntimeSessionChange: vi.fn(async () => {}), + renderCurrentSessionState: vi.fn(), + handleFatalRuntimeError: vi.fn(async () => { + throw new Error("unexpected fatal error"); + }), + promptForMissingSessionCwd: vi.fn(async () => undefined), + getPathCommandArgument: interactiveModePrototype.getPathCommandArgument, + }; + + await interactiveModePrototype.handleImportCommand.call(context, "/import john's/session.jsonl"); + + expect(importFromJsonl).toHaveBeenCalledWith("john's/session.jsonl"); + expect(showError).not.toHaveBeenCalled(); + expect(showStatus).toHaveBeenCalledWith("Session imported from: john's/session.jsonl"); + }); + + it("shows a non-fatal error when /import path does not exist", async () => { + const importFromJsonl = vi.fn(async () => { + throw new SessionImportFileNotFoundError("/tmp/missing-session.jsonl"); + }); + const showExtensionConfirm = vi.fn(async () => true); + const showStatus = vi.fn(); + const showError = vi.fn(); + const handleFatalRuntimeError = vi.fn(async () => { + throw new Error("unexpected fatal error"); + }); + + const context: ImportCommandContext = { + statusContainer: { clear: vi.fn() }, + runtimeHost: { importFromJsonl }, + showError, + showStatus, + showExtensionConfirm, + handleRuntimeSessionChange: vi.fn(async () => {}), + renderCurrentSessionState: vi.fn(), + handleFatalRuntimeError, + promptForMissingSessionCwd: vi.fn(async () => undefined), + getPathCommandArgument: interactiveModePrototype.getPathCommandArgument, + }; + + await interactiveModePrototype.handleImportCommand.call(context, "/import /tmp/missing-session.jsonl"); + + expect(showError).toHaveBeenCalledWith("Failed to import session: File not found: /tmp/missing-session.jsonl"); + expect(showStatus).not.toHaveBeenCalled(); + expect(handleFatalRuntimeError).not.toHaveBeenCalled(); + }); +});