diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index cfdd16120..a4a8c6a81 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -357,6 +357,9 @@ case 'thinking_level_change': parts.push('thinking', entry.thinkingLevel); break; + case 'plan': + parts.push('plan', entry.plan.title, entry.plan.markdown); + break; } return parts.join(' ').toLowerCase(); @@ -385,7 +388,14 @@ } // Apply filter mode - const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change'].includes(entry.type); + const isSettingsEntry = [ + 'label', + 'custom', + 'model_change', + 'thinking_level_change', + 'collaboration_mode_change', + 'plan_resolution' + ].includes(entry.type); let passesFilter = true; switch (filterMode) { @@ -696,6 +706,12 @@ return labelHtml + `[model: ${escapeHtml(entry.modelId)}]`; case 'thinking_level_change': return labelHtml + `[thinking: ${escapeHtml(entry.thinkingLevel)}]`; + case 'collaboration_mode_change': + return labelHtml + `[mode: ${escapeHtml(entry.mode)}]`; + case 'plan_resolution': + return labelHtml + `[plan resolution: ${escapeHtml(entry.resolution)}]`; + case 'plan': + return labelHtml + `[plan]: ${escapeHtml(truncate(normalize(entry.plan.title)))}`; default: return labelHtml + `[${escapeHtml(entry.type)}]`; } @@ -1306,6 +1322,13 @@ `; } + if (entry.type === 'plan') { + return `
${copyBtnHtml}${tsHtml} +
Plan: ${escapeHtml(entry.plan.title)}
+
${safeMarkedParse(entry.plan.markdown)}
+
`; + } + if (entry.type === 'custom_message' && entry.display) { return `
${tsHtml}
[${escapeHtml(entry.customType)}]
diff --git a/packages/coding-agent/src/modes/json-event.ts b/packages/coding-agent/src/modes/json-event.ts index 6cff8ebf2..91e5cc88b 100644 --- a/packages/coding-agent/src/modes/json-event.ts +++ b/packages/coding-agent/src/modes/json-event.ts @@ -13,7 +13,13 @@ type ToJsonEvent = T extends { : T; /** Session event shape emitted by the JSON and RPC stdout protocols. */ -export type JsonAgentSessionEvent = ToJsonEvent; +type PlanOnlyAgentSessionEvent = Extract< + AgentSessionEvent, + { type: "collaboration_mode_changed" | "plan_submitted" | "plan_resolved" } +>; +type WireAgentSessionEvent = Exclude; + +export type JsonAgentSessionEvent = ToJsonEvent; type MessageUpdateEvent = Extract; type JsonMessageUpdateEvent = Extract; @@ -24,8 +30,16 @@ type JsonMessageUpdateEvent = Extract { + assertNonInteractivePlanModeDisabled(runtimeHost.session); const { mode, messages = [], initialMessage, initialImages } = options; let exitCode = 0; let session = runtimeHost.session; @@ -72,17 +77,29 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr }); const rebindSession = async (): Promise => { - session = runtimeHost.session; + const nextSession = runtimeHost.session; + assertNonInteractivePlanModeDisabled(nextSession); + session = nextSession; await session.bindExtensions({ mode: mode === "json" ? "json" : "print", commandContextActions: { waitForIdle: () => session.waitForIdle(), - newSession: async (newSessionOptions) => runtimeHost.newSession(newSessionOptions), + newSession: async (newSessionOptions) => + runtimeHost.newSession({ + ...newSessionOptions, + validateTarget: validateNonInteractiveSessionTarget, + }), fork: async (entryId, forkOptions) => { - const result = await runtimeHost.fork(entryId, forkOptions); + const result = await runtimeHost.fork(entryId, { + ...forkOptions, + validateTarget: validateNonInteractiveSessionTarget, + }); return { cancelled: result.cancelled }; }, navigateTree: async (targetId, navigateOptions) => { + if (session.sessionManager.getEntry(targetId)) { + validateNonInteractiveSessionTarget(session.sessionManager, targetId); + } const result = await session.navigateTree(targetId, { summarize: navigateOptions?.summarize, customInstructions: navigateOptions?.customInstructions, @@ -92,7 +109,10 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr return { cancelled: result.cancelled }; }, switchSession: async (sessionPath, switchOptions) => { - return runtimeHost.switchSession(sessionPath, switchOptions); + return runtimeHost.switchSession(sessionPath, { + ...switchOptions, + validateTarget: validateNonInteractiveSessionTarget, + }); }, reload: async () => { await session.reload(); @@ -107,7 +127,10 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr unsubscribeBackpressure?.(); unsubscribe = session.subscribe((event) => { if (mode === "json") { - writeRawStdout(`${JSON.stringify(toJsonEvent(event))}\n`); + const jsonEvent = toJsonEvent(event); + if (jsonEvent) { + writeRawStdout(`${JSON.stringify(jsonEvent)}\n`); + } } }); unsubscribeBackpressure = @@ -129,11 +152,15 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr await rebindSession(); if (initialMessage) { - await session.prompt(initialMessage, { images: initialImages }); + assertNonInteractivePlanModeDisabled(session); + await runtimeHost.prompt(initialMessage, { images: initialImages }); + assertNonInteractivePlanModeDisabled(session); } for (const message of messages) { - await session.prompt(message); + assertNonInteractivePlanModeDisabled(session); + await runtimeHost.prompt(message); + assertNonInteractivePlanModeDisabled(session); } if (mode === "text") { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 1b5d8a656..933b508b9 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -28,6 +28,10 @@ import { import { killTrackedDetachedChildren } from "../../utils/shell.ts"; import { type Theme, theme } from "../interactive/theme/theme.ts"; import { toJsonEvent } from "../json-event.ts"; +import { + assertNonInteractivePlanModeDisabled, + validateNonInteractiveSessionTarget, +} from "../non-interactive-plan-mode.ts"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.ts"; import type { RpcCommand, @@ -52,6 +56,7 @@ export type { * Listens for JSON commands on stdin, outputs events and responses on stdout. */ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { + assertNonInteractivePlanModeDisabled(runtimeHost.session); takeOverStdout(); let session = runtimeHost.session; let unsubscribe: (() => void) | undefined; @@ -315,18 +320,30 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise => { - session = runtimeHost.session; + const nextSession = runtimeHost.session; + assertNonInteractivePlanModeDisabled(nextSession); + session = nextSession; await session.bindExtensions({ uiContext: createExtensionUIContext(), mode: "rpc", commandContextActions: { waitForIdle: () => session.waitForIdle(), - newSession: async (options) => runtimeHost.newSession(options), + newSession: async (options) => + runtimeHost.newSession({ + ...options, + validateTarget: validateNonInteractiveSessionTarget, + }), fork: async (entryId, forkOptions) => { - const result = await runtimeHost.fork(entryId, forkOptions); + const result = await runtimeHost.fork(entryId, { + ...forkOptions, + validateTarget: validateNonInteractiveSessionTarget, + }); return { cancelled: result.cancelled }; }, navigateTree: async (targetId, options) => { + if (session.sessionManager.getEntry(targetId)) { + validateNonInteractiveSessionTarget(session.sessionManager, targetId); + } const result = await session.navigateTree(targetId, { summarize: options?.summarize, customInstructions: options?.customInstructions, @@ -336,7 +353,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { - return runtimeHost.switchSession(sessionPath, options); + return runtimeHost.switchSession(sessionPath, { + ...options, + validateTarget: validateNonInteractiveSessionTarget, + }); }, reload: async () => { await session.reload(); @@ -353,7 +373,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { - output(toJsonEvent(event)); + const jsonEvent = toJsonEvent(event); + if (jsonEvent) { + output(jsonEvent); + } if (event.type === "agent_settled") { void checkShutdownRequested(); } @@ -382,6 +405,34 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise["images"], + streamingBehavior?: "steer" | "followUp", + ): void => { + assertNonInteractivePlanModeDisabled(session); + let preflightSucceeded = false; + void runtimeHost + .prompt(message, { + images, + streamingBehavior, + source: "rpc", + preflightResult: (didSucceed) => { + if (didSucceed && !preflightSucceeded) { + preflightSucceeded = true; + output(success(id, command)); + } + }, + }) + .catch((promptError) => { + if (!preflightSucceeded) { + output(error(id, command, promptError instanceof Error ? promptError.message : String(promptError))); + } + }); + }; + // Handle a single command const handleCommand = async (command: RpcCommand): Promise => { const id = command.id; @@ -394,35 +445,18 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { - if (didSucceed) { - preflightSucceeded = true; - output(success(id, "prompt")); - } - }, - }) - .catch((e) => { - if (!preflightSucceeded) { - output(error(id, "prompt", e.message)); - } - }); + dispatchPromptCommand(id, "prompt", command.message, command.images, command.streamingBehavior); return undefined; } case "steer": { - await session.steer(command.message, command.images); - return success(id, "steer"); + dispatchPromptCommand(id, "steer", command.message, command.images, "steer"); + return undefined; } case "follow_up": { - await session.followUp(command.message, command.images); - return success(id, "follow_up"); + dispatchPromptCommand(id, "follow_up", command.message, command.images, "followUp"); + return undefined; } case "abort": { @@ -431,7 +465,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { + const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8"); + + it("renders and indexes plan artifacts while hiding plan bookkeeping by default", () => { + expect(templateJs).toContain("parts.push('plan', entry.plan.title, entry.plan.markdown)"); + expect(templateJs).toMatch( + /const isSettingsEntry = \[[\s\S]*?'collaboration_mode_change',[\s\S]*?'plan_resolution'[\s\S]*?\]\.includes\(entry\.type\);/, + ); + expect(templateJs).toMatch(/\[mode: \$\{escapeHtml\(entry\.mode\)\}\]/); + expect(templateJs).toMatch(/\[plan resolution: \$\{escapeHtml\(entry\.resolution\)\}\]/); + expect(templateJs).toContain("escapeHtml(truncate(normalize(entry.plan.title)))"); + expect(templateJs).toMatch(/Plan: \$\{escapeHtml\(entry\.plan\.title\)\}/); + expect(templateJs).toContain("safeMarkedParse(entry.plan.markdown)"); + }); +}); diff --git a/packages/coding-agent/test/print-mode.test.ts b/packages/coding-agent/test/print-mode.test.ts index 9725442ce..cce78872f 100644 --- a/packages/coding-agent/test/print-mode.test.ts +++ b/packages/coding-agent/test/print-mode.test.ts @@ -1,5 +1,10 @@ import type { AssistantMessage, ImageContent } from "@earendil-works/diffex-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentSessionEvent, PlanContinuation } from "../src/core/agent-session.ts"; +import type { AgentSessionRuntime } from "../src/core/agent-session-runtime.ts"; +import type { ExtensionCommandContextActions } from "../src/core/extensions/types.ts"; +import type { PlanArtifact } from "../src/core/plan-mode.ts"; +import { SessionManager } from "../src/core/session-manager.ts"; import type { SessionShutdownEvent } from "../src/index.ts"; import { runPrintMode } from "../src/modes/print-mode.ts"; @@ -11,23 +16,31 @@ type FakeExtensionRunner = { }; type FakeSession = { - sessionManager: { getHeader: () => object | undefined }; + collaborationMode: "default" | "plan"; + pendingPlan: PlanArtifact | undefined; + pendingPlanContinuation: PlanContinuation | undefined; + sessionManager: SessionManager; agent: { waitForIdle: () => Promise; subscribe: ReturnType }; state: { messages: AssistantMessage[] }; extensionRunner: FakeExtensionRunner; bindExtensions: ReturnType; subscribe: ReturnType; - prompt: ReturnType; + prompt: ReturnType Promise>>; + waitForIdle: ReturnType Promise>>; + navigateTree: ReturnType; reload: ReturnType; }; type FakeRuntimeHost = { session: FakeSession; + prompt: ReturnType Promise>>; newSession: ReturnType; fork: ReturnType; switchSession: ReturnType; dispose: ReturnType; setRebindSession: ReturnType; + setProfileSwitchListener: ReturnType; + commandContextActions?: ExtensionCommandContextActions; }; function createAssistantMessage(options?: { @@ -55,35 +68,114 @@ function createAssistantMessage(options?: { }; } -function createRuntimeHost(assistantMessage: AssistantMessage): FakeRuntimeHost { +function createRuntimeHost( + assistantMessage: AssistantMessage, + emitProfileSwitch = false, + activationTarget?: { sessionManager: SessionManager; leafId: string | null }, +): FakeRuntimeHost { const extensionRunner: FakeExtensionRunner = { hasHandlers: (eventType: string) => eventType === "session_shutdown", emit: vi.fn(async () => {}), }; const state = { messages: [assistantMessage] }; + const sessionManager = activationTarget?.sessionManager ?? SessionManager.inMemory(); const session: FakeSession = { - sessionManager: { getHeader: () => undefined }, + collaborationMode: "default", + pendingPlan: undefined, + pendingPlanContinuation: undefined, + sessionManager, agent: { waitForIdle: async () => {}, subscribe: vi.fn(() => () => {}) }, state, extensionRunner, bindExtensions: vi.fn(async () => {}), subscribe: vi.fn(() => () => {}), prompt: vi.fn(async () => {}), + waitForIdle: vi.fn(async () => {}), + navigateTree: vi.fn(async () => ({ cancelled: false })), reload: vi.fn(async () => {}), }; - return { + const runtimeHost: FakeRuntimeHost = { session, - newSession: vi.fn(async () => undefined), - fork: vi.fn(async () => ({ selectedText: "" })), - switchSession: vi.fn(async () => undefined), + prompt: vi.fn(async (text: string, options?: unknown) => { + if (options === undefined) { + await session.prompt(text); + } else { + await session.prompt(text, options); + } + }), + newSession: vi.fn(async (options?: Parameters[0]) => { + if (activationTarget) { + options?.validateTarget?.(activationTarget.sessionManager, activationTarget.leafId); + } + return { cancelled: true }; + }), + fork: vi.fn(async (_entryId: string, options?: Parameters[1]) => { + if (activationTarget) { + options?.validateTarget?.(activationTarget.sessionManager, activationTarget.leafId); + } + return { cancelled: true, selectedText: "" }; + }), + switchSession: vi.fn( + async (_sessionPath: string, options?: Parameters[1]) => { + if (activationTarget) { + options?.validateTarget?.(activationTarget.sessionManager, activationTarget.leafId); + } + return { cancelled: true }; + }, + ), dispose: vi.fn(async () => { await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" }); }), setRebindSession: vi.fn(), + setProfileSwitchListener: vi.fn(), }; + session.bindExtensions.mockImplementation( + async (options: { commandContextActions?: ExtensionCommandContextActions }) => { + runtimeHost.commandContextActions = options.commandContextActions; + }, + ); + runtimeHost.setProfileSwitchListener.mockImplementation((listener) => { + if (listener && emitProfileSwitch) { + runtimeHost.prompt.mockImplementationOnce(async (text: string, options?: unknown) => { + if (options === undefined) await session.prompt(text); + else await session.prompt(text, options); + listener({ + fromProfileId: "general", + toProfileId: "research", + reason: "private reason", + harnessId: "evidence-review-a1b2c3", + harnessRevision: "abcdef1234567890", + harnessName: "Evidence review", + reused: true, + notice: 'Found evolved harness "Evidence review" for this task. Switched to revision abcdef123456.', + }); + }); + } + }); + return runtimeHost; +} + +function createLifecycleInvalidTarget(): { sessionManager: SessionManager; leafId: string } { + const sessionManager = SessionManager.inMemory(); + const modeEntryId = sessionManager.appendCollaborationModeChange("plan"); + const plan = { id: "invalid-plan", title: "Invalid target", markdown: "1. Remain noninteractive." }; + const leafId = sessionManager.appendPlan(plan); + const modeEntry = sessionManager.getEntry(modeEntryId); + if (modeEntry?.type !== "collaboration_mode_change") throw new Error("Expected collaboration mode entry"); + modeEntry.mode = "default"; + return { sessionManager, leafId }; +} + +function createPendingPlanContinuationTarget(): { sessionManager: SessionManager; leafId: string } { + const sessionManager = SessionManager.inMemory(); + sessionManager.appendCollaborationModeChange("plan"); + const plan = { id: "accepted-plan", title: "Accepted plan", markdown: "1. Implement interactively." }; + sessionManager.appendPlan(plan); + const leafId = sessionManager.appendPlanResolution(plan.id, "implement"); + return { sessionManager, leafId }; } afterEach(() => { @@ -91,6 +183,121 @@ afterEach(() => { }); describe("runPrintMode", () => { + it.each(["text", "json"] as const)("rejects an initial plan-mode session in %s mode", async (mode) => { + const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "unused" })); + runtimeHost.session.collaborationMode = "plan"; + + await expect( + runPrintMode(runtimeHost as unknown as Parameters[0], { mode }), + ).rejects.toThrow("Plan mode is not supported in print, JSON, or RPC mode"); + expect(runtimeHost.prompt).not.toHaveBeenCalled(); + expect(runtimeHost.dispose).not.toHaveBeenCalled(); + }); + + it("rejects an initial lifecycle-invalid session", async () => { + const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "unused" })); + runtimeHost.session.pendingPlan = { + id: "orphaned-plan", + title: "Orphaned plan", + markdown: "1. Do not start noninteractive mode.", + }; + + await expect( + runPrintMode(runtimeHost as unknown as Parameters[0], { mode: "text" }), + ).rejects.toThrow("Plan mode is not supported in print, JSON, or RPC mode"); + expect(runtimeHost.dispose).not.toHaveBeenCalled(); + }); + + it("rejects an initial session with an accepted plan awaiting continuation", async () => { + const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "unused" })); + runtimeHost.session.pendingPlanContinuation = Object.freeze({ message: "Implement the accepted plan." }); + + await expect( + runPrintMode(runtimeHost as unknown as Parameters[0], { mode: "text" }), + ).rejects.toThrow("Plan mode is not supported in print, JSON, or RPC mode"); + expect(runtimeHost.prompt).not.toHaveBeenCalled(); + expect(runtimeHost.dispose).not.toHaveBeenCalled(); + }); + + it.each(["newSession", "switchSession", "fork", "navigateTree"] as const)( + "rejects a lifecycle-invalid %s extension target before mutation", + async (action) => { + const activationTarget = createLifecycleInvalidTarget(); + const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" }), false, activationTarget); + + const exitCode = await runPrintMode(runtimeHost as unknown as Parameters[0], { + mode: "text", + }); + expect(exitCode).toBe(0); + const actions = runtimeHost.commandContextActions; + if (!actions) throw new Error("Expected bound print extension command actions"); + + const invocation = + action === "newSession" + ? actions.newSession() + : action === "switchSession" + ? actions.switchSession("/target/session.jsonl") + : action === "fork" + ? actions.fork("source-entry") + : actions.navigateTree(activationTarget.leafId); + await expect(invocation).rejects.toThrow(/Plan entry .* is outside plan mode/); + if (action === "navigateTree") { + expect(runtimeHost.session.navigateTree).not.toHaveBeenCalled(); + } + }, + ); + + it.each(["newSession", "switchSession", "fork", "navigateTree"] as const)( + "rejects an accepted-plan continuation %s extension target before mutation", + async (action) => { + const activationTarget = createPendingPlanContinuationTarget(); + const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" }), false, activationTarget); + + const exitCode = await runPrintMode(runtimeHost as unknown as Parameters[0], { + mode: "text", + }); + expect(exitCode).toBe(0); + const actions = runtimeHost.commandContextActions; + if (!actions) throw new Error("Expected bound print extension command actions"); + + const invocation = + action === "newSession" + ? actions.newSession() + : action === "switchSession" + ? actions.switchSession("/target/session.jsonl") + : action === "fork" + ? actions.fork("source-entry") + : actions.navigateTree(activationTarget.leafId); + await expect(invocation).rejects.toThrow("Plan mode is not supported in print, JSON, or RPC mode"); + if (action === "navigateTree") { + expect(runtimeHost.session.navigateTree).not.toHaveBeenCalled(); + } + }, + ); + + it.each(["plan mode", "accepted-plan continuation"] as const)( + "checks %s state before every queued prompt", + async (state) => { + const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" })); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + runtimeHost.prompt.mockImplementationOnce(async () => { + if (state === "plan mode") runtimeHost.session.collaborationMode = "plan"; + else + runtimeHost.session.pendingPlanContinuation = Object.freeze({ message: "Implement the accepted plan." }); + }); + + const exitCode = await runPrintMode(runtimeHost as unknown as Parameters[0], { + mode: "text", + initialMessage: "first", + messages: ["second"], + }); + + expect(exitCode).toBe(1); + expect(runtimeHost.prompt).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith("Plan mode is not supported in print, JSON, or RPC mode."); + }, + ); + it("emits session_shutdown in text mode", async () => { const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" })); const { session } = runtimeHost; @@ -123,6 +330,81 @@ describe("runPrintMode", () => { expect(session.extensionRunner.emit).toHaveBeenCalledWith({ type: "session_shutdown", reason: "quit" }); }); + it("writes a harness notice to stderr without corrupting text stdout", async () => { + const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" }), true); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const exitCode = await runPrintMode(runtimeHost as unknown as Parameters[0], { + mode: "text", + initialMessage: "Review evidence", + }); + + expect(exitCode).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + 'Found evolved harness "Evidence review" for this task. Switched to revision abcdef123456.', + ); + }); + + it("emits a typed reason-free harness event in json mode", async () => { + const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" }), true); + const output: string[] = []; + const outputSpy = vi.spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + callbackOrEncoding?: BufferEncoding | (() => void), + callback?: () => void, + ) => { + output.push(String(chunk)); + if (typeof callbackOrEncoding === "function") callbackOrEncoding(); + else callback?.(); + return true; + }) as typeof process.stdout.write); + + const exitCode = await runPrintMode(runtimeHost as unknown as Parameters[0], { + mode: "json", + initialMessage: "Review evidence", + }); + + expect(exitCode).toBe(0); + expect(output.join("")).toContain('"type":"profile_switch"'); + expect(output.join("")).toContain('"harnessId":"evidence-review-a1b2c3"'); + expect(output.join("")).not.toContain("private reason"); + outputSpy.mockRestore(); + }); + + it("suppresses native plan lifecycle events in json mode", async () => { + const runtimeHost = createRuntimeHost(createAssistantMessage({ text: "done" })); + const plan = { id: "plan-1", title: "Hidden plan", markdown: "1. Keep this off the JSON wire." }; + runtimeHost.session.subscribe.mockImplementation((listener: (event: AgentSessionEvent) => void) => { + listener({ type: "collaboration_mode_changed", mode: "plan" }); + listener({ type: "plan_submitted", plan }); + listener({ type: "plan_resolved", planId: plan.id, resolution: "discard" }); + listener({ type: "agent_start" }); + return () => {}; + }); + const output: string[] = []; + const outputSpy = vi.spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + callbackOrEncoding?: BufferEncoding | (() => void), + callback?: () => void, + ) => { + output.push(String(chunk)); + if (typeof callbackOrEncoding === "function") callbackOrEncoding(); + else callback?.(); + return true; + }) as typeof process.stdout.write); + + const exitCode = await runPrintMode(runtimeHost as unknown as Parameters[0], { + mode: "json", + }); + + expect(exitCode).toBe(0); + expect(output.join("")).toContain('"type":"agent_start"'); + expect(output.join("")).not.toContain("collaboration_mode_changed"); + expect(output.join("")).not.toContain("plan_submitted"); + expect(output.join("")).not.toContain("plan_resolved"); + outputSpy.mockRestore(); + }); + it("emits session_shutdown and returns non-zero on assistant error", async () => { const runtimeHost = createRuntimeHost( createAssistantMessage({ stopReason: "error", errorMessage: "provider failure" }), diff --git a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts index bb6f27c8a..9d7d2bdc5 100644 --- a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts +++ b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts @@ -9,13 +9,16 @@ import { getModel, type Model, } from "@earendil-works/diffex-ai/compat"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; import { AgentSession } from "../src/core/agent-session.ts"; import type { AgentSessionRuntime } from "../src/core/agent-session-runtime.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; +import type { ExtensionCommandContextActions } from "../src/core/extensions/types.ts"; +import { SUBMIT_PLAN_TOOL_NAME } from "../src/core/plan-mode.ts"; import { SessionManager } from "../src/core/session-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; import { runRpcMode } from "../src/modes/rpc/rpc-mode.ts"; +import type { RpcEvent } from "../src/modes/rpc/rpc-types.ts"; import { createInMemoryModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts"; import { createTestResourceLoader } from "./utilities.ts"; @@ -91,11 +94,48 @@ function getPromptResponses(outputLines: string[], id: string): ParsedOutputLine ); } +function getCommandResponses(outputLines: string[], id: string, command: string): ParsedOutputLine[] { + return parseOutputLines(outputLines).filter( + (record) => record.id === id && record.type === "response" && record.command === command, + ); +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: number; model?: Model }): Promise<{ +function createPlanModeTarget(): { sessionManager: SessionManager; leafId: string } { + const sessionManager = SessionManager.inMemory(); + const leafId = sessionManager.appendCollaborationModeChange("plan"); + return { sessionManager, leafId }; +} + +function createPendingPlanContinuationTarget(): { sessionManager: SessionManager; leafId: string } { + const sessionManager = SessionManager.inMemory(); + sessionManager.appendCollaborationModeChange("plan"); + const plan = { id: "accepted-plan", title: "Accepted plan", markdown: "1. Implement interactively." }; + sessionManager.appendPlan(plan); + const leafId = sessionManager.appendPlanResolution(plan.id, "implement"); + return { sessionManager, leafId }; +} + +function createLifecycleInvalidTarget(): { sessionManager: SessionManager; leafId: string } { + const sessionManager = SessionManager.inMemory(); + const modeEntryId = sessionManager.appendCollaborationModeChange("plan"); + const plan = { id: "invalid-plan", title: "Invalid target", markdown: "1. Remain noninteractive." }; + const leafId = sessionManager.appendPlan(plan); + const modeEntry = sessionManager.getEntry(modeEntryId); + if (modeEntry?.type !== "collaboration_mode_change") throw new Error("Expected collaboration mode entry"); + modeEntry.mode = "default"; + return { sessionManager, leafId }; +} + +async function createRuntimeHost(options: { + withAuth: boolean; + responseDelayMs: number; + model?: Model; + activationTarget?: { sessionManager: SessionManager; leafId: string | null }; +}): Promise<{ runtimeHost: AgentSessionRuntime; cleanup: () => Promise; }> { @@ -114,13 +154,25 @@ async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: systemPrompt: "Test", tools: [], }, - streamFn: (_model, _context, _options) => { + streamFn: (_model, _context, streamOptions) => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: createAssistantMessage("") }); - setTimeout(() => { + const responseTimer = setTimeout(() => { stream.push({ type: "done", reason: "stop", message: createAssistantMessage("done") }); }, options.responseDelayMs); + streamOptions?.signal?.addEventListener( + "abort", + () => { + clearTimeout(responseTimer); + stream.push({ + type: "error", + reason: "aborted", + error: { ...createAssistantMessage("aborted"), stopReason: "aborted" }, + }); + }, + { once: true }, + ); }); return stream; }, @@ -143,11 +195,38 @@ async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: resourceLoader: createTestResourceLoader(), }); + const runtimePrompt = vi.fn(async (text: string, promptOptions?: Parameters[1]) => + session.prompt(text, promptOptions), + ); const runtimeHost = { session, - newSession: vi.fn(async () => ({ cancelled: true })), - switchSession: vi.fn(async () => ({ cancelled: true })), - fork: vi.fn(async () => ({ cancelled: true, selectedText: "" })), + prompt: runtimePrompt, + newSession: vi.fn(async (newSessionOptions?: Parameters[0]) => { + if (options.activationTarget) { + newSessionOptions?.validateTarget?.( + options.activationTarget.sessionManager, + options.activationTarget.leafId, + ); + } + return { cancelled: true }; + }), + switchSession: vi.fn( + async (_sessionPath: string, switchOptions?: Parameters[1]) => { + if (options.activationTarget) { + switchOptions?.validateTarget?.( + options.activationTarget.sessionManager, + options.activationTarget.leafId, + ); + } + return { cancelled: true }; + }, + ), + fork: vi.fn(async (_entryId: string, forkOptions?: Parameters[1]) => { + if (options.activationTarget) { + forkOptions?.validateTarget?.(options.activationTarget.sessionManager, options.activationTarget.leafId); + } + return { cancelled: true, selectedText: "" }; + }), dispose: vi.fn(async () => {}), setRebindSession: vi.fn(), } as unknown as AgentSessionRuntime; @@ -170,8 +249,14 @@ async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: }; } -async function startRpcMode(options: { withAuth: boolean; responseDelayMs: number; model?: Model }): Promise<{ +async function startRpcMode(options: { + withAuth: boolean; + responseDelayMs: number; + model?: Model; + activationTarget?: { sessionManager: SessionManager; leafId: string | null }; +}): Promise<{ lineHandler: (line: string) => void; + runtimeHost: AgentSessionRuntime; cleanup: () => Promise; }> { rpcIo.outputLines = []; @@ -181,7 +266,7 @@ async function startRpcMode(options: { withAuth: boolean; responseDelayMs: numbe void runRpcMode(runtimeHost); await vi.waitFor(() => expect(rpcIo.lineHandler).toBeDefined()); - return { lineHandler: rpcIo.lineHandler!, cleanup }; + return { lineHandler: rpcIo.lineHandler!, runtimeHost, cleanup }; } describe("RPC prompt response semantics", () => { @@ -190,6 +275,44 @@ describe("RPC prompt response semantics", () => { rpcIo.lineHandler = undefined; }); + it("rejects an initial plan-mode session before taking over RPC I/O", async () => { + const { runtimeHost, cleanup } = await createRuntimeHost({ withAuth: true, responseDelayMs: 0 }); + runtimeHost.session.setCollaborationMode("plan"); + + try { + await expect(runRpcMode(runtimeHost)).rejects.toThrow( + "Plan mode is not supported in print, JSON, or RPC mode", + ); + expect(rpcIo.lineHandler).toBeUndefined(); + } finally { + await cleanup(); + } + }); + + it("rejects an initial accepted plan continuation before taking over RPC I/O", async () => { + const { runtimeHost, cleanup } = await createRuntimeHost({ withAuth: true, responseDelayMs: 0 }); + const session = runtimeHost.session; + session.setCollaborationMode("plan"); + const submitPlan = session.agent.state.tools.find((tool) => tool.name === SUBMIT_PLAN_TOOL_NAME); + if (!submitPlan) throw new Error("Expected submit_plan in plan mode"); + await submitPlan.execute("rpc-accepted-plan", { + title: "Accepted plan", + markdown: "1. Continue only through the interactive host.", + }); + const plan = session.pendingPlan; + if (!plan) throw new Error("Expected a pending plan"); + session.resolvePlan(plan.id, "implement"); + + try { + await expect(runRpcMode(runtimeHost)).rejects.toThrow( + "Plan mode is not supported in print, JSON, or RPC mode", + ); + expect(rpcIo.lineHandler).toBeUndefined(); + } finally { + await cleanup(); + } + }); + it("emits one failure response when prompt preflight rejects", async () => { const { lineHandler, cleanup } = await startRpcMode({ withAuth: false, @@ -250,6 +373,215 @@ describe("RPC prompt response semantics", () => { } }); + it("keeps native plan lifecycle events off the RPC wire", async () => { + type PlanLifecycleRpcEvent = Extract< + RpcEvent, + { type: "collaboration_mode_changed" | "plan_submitted" | "plan_resolved" } + >; + expectTypeOf().toEqualTypeOf(); + + const { runtimeHost, cleanup } = await startRpcMode({ withAuth: true, responseDelayMs: 0 }); + + try { + rpcIo.outputLines = []; + const session = runtimeHost.session; + session.setCollaborationMode("plan"); + const submitPlan = session.agent.state.tools.find((tool) => tool.name === SUBMIT_PLAN_TOOL_NAME); + if (!submitPlan) throw new Error("Expected submit_plan in plan mode"); + await submitPlan.execute("rpc-plan", { + title: "RPC-hidden plan", + markdown: "1. Keep plan lifecycle state inside the interactive and SDK surfaces.", + }); + const plan = session.pendingPlan; + if (!plan) throw new Error("Expected a pending plan"); + session.resolvePlan(plan.id, "discard"); + + const outputTypes = parseOutputLines(rpcIo.outputLines).map((record) => record.type); + expect(outputTypes).not.toContain("collaboration_mode_changed"); + expect(outputTypes).not.toContain("plan_submitted"); + expect(outputTypes).not.toContain("plan_resolved"); + } finally { + await cleanup(); + } + }); + + it.each([ + ["plan-mode", createPlanModeTarget], + ["accepted-plan continuation", createPendingPlanContinuationTarget], + ] as const)("rejects %s targets for direct session replacement commands", async (_label, createTarget) => { + const activationTarget = createTarget(); + const { lineHandler, runtimeHost, cleanup } = await startRpcMode({ + withAuth: true, + responseDelayMs: 0, + activationTarget, + }); + + try { + runtimeHost.session.sessionManager.appendCollaborationModeChange("default"); + for (const [id, command] of [ + ["new-plan", { type: "new_session" }], + ["switch-plan", { type: "switch_session", sessionPath: "/target/session.jsonl" }], + ["fork-plan", { type: "fork", entryId: "source-entry" }], + ["clone-plan", { type: "clone" }], + ] as const) { + lineHandler(JSON.stringify({ id, ...command })); + await vi.waitFor(() => { + expect(getCommandResponses(rpcIo.outputLines, id, command.type)).toEqual([ + expect.objectContaining({ + success: false, + error: "Plan mode is not supported in print, JSON, or RPC mode.", + }), + ]); + }); + } + expect(runtimeHost.newSession).toHaveBeenCalledTimes(1); + expect(runtimeHost.switchSession).toHaveBeenCalledTimes(1); + expect(runtimeHost.fork).toHaveBeenCalledTimes(2); + } finally { + await cleanup(); + } + }); + + it("rejects accepted-plan continuation targets for extension session actions before mutation", async () => { + const activationTarget = createPendingPlanContinuationTarget(); + const { runtimeHost, cleanup } = await startRpcMode({ + withAuth: true, + responseDelayMs: 0, + activationTarget, + }); + + try { + const actions = ( + runtimeHost.session as unknown as { + _extensionCommandContextActions?: ExtensionCommandContextActions; + } + )._extensionCommandContextActions; + if (!actions) throw new Error("Expected bound RPC extension command actions"); + + await expect(actions.newSession()).rejects.toThrow("Plan mode is not supported in print, JSON, or RPC mode"); + await expect(actions.switchSession("/target/session.jsonl")).rejects.toThrow( + "Plan mode is not supported in print, JSON, or RPC mode", + ); + await expect(actions.fork("source-entry")).rejects.toThrow( + "Plan mode is not supported in print, JSON, or RPC mode", + ); + + const sessionManager = runtimeHost.session.sessionManager; + const defaultLeafId = sessionManager.appendCollaborationModeChange("default"); + sessionManager.appendCollaborationModeChange("plan"); + const plan = { id: "tree-plan", title: "Tree plan", markdown: "1. Stay interactive." }; + sessionManager.appendPlan(plan); + const continuationLeafId = sessionManager.appendPlanResolution(plan.id, "implement"); + sessionManager.branch(defaultLeafId); + const navigateTree = vi.spyOn(runtimeHost.session, "navigateTree"); + await expect(actions.navigateTree(continuationLeafId)).rejects.toThrow( + "Plan mode is not supported in print, JSON, or RPC mode", + ); + expect(navigateTree).not.toHaveBeenCalled(); + expect(sessionManager.getLeafId()).toBe(defaultLeafId); + } finally { + await cleanup(); + } + }); + + it("rejects plan-mode targets for extension session actions before current-tree mutation", async () => { + const activationTarget = createPlanModeTarget(); + const { runtimeHost, cleanup } = await startRpcMode({ + withAuth: true, + responseDelayMs: 0, + activationTarget, + }); + + try { + const actions = ( + runtimeHost.session as unknown as { + _extensionCommandContextActions?: ExtensionCommandContextActions; + } + )._extensionCommandContextActions; + if (!actions) throw new Error("Expected bound RPC extension command actions"); + + await expect(actions.newSession()).rejects.toThrow("Plan mode is not supported in print, JSON, or RPC mode"); + await expect(actions.switchSession("/target/session.jsonl")).rejects.toThrow( + "Plan mode is not supported in print, JSON, or RPC mode", + ); + await expect(actions.fork("source-entry")).rejects.toThrow( + "Plan mode is not supported in print, JSON, or RPC mode", + ); + + const sessionManager = runtimeHost.session.sessionManager; + const planLeafId = sessionManager.appendCollaborationModeChange("plan"); + const defaultLeafId = sessionManager.appendCollaborationModeChange("default"); + const navigateTree = vi.spyOn(runtimeHost.session, "navigateTree"); + await expect(actions.navigateTree(planLeafId)).rejects.toThrow( + "Plan mode is not supported in print, JSON, or RPC mode", + ); + expect(navigateTree).not.toHaveBeenCalled(); + expect(sessionManager.getLeafId()).toBe(defaultLeafId); + } finally { + await cleanup(); + } + }); + + it("rejects a lifecycle-invalid target before RPC adoption", async () => { + const activationTarget = createLifecycleInvalidTarget(); + const { lineHandler, runtimeHost, cleanup } = await startRpcMode({ + withAuth: true, + responseDelayMs: 0, + activationTarget, + }); + + try { + lineHandler(JSON.stringify({ id: "invalid-new", type: "new_session" })); + await vi.waitFor(() => { + expect(getCommandResponses(rpcIo.outputLines, "invalid-new", "new_session")).toEqual([ + expect.objectContaining({ + success: false, + error: expect.stringMatching(/Plan entry .* is outside plan mode/), + }), + ]); + }); + expect(runtimeHost.session.collaborationMode).toBe("default"); + } finally { + await cleanup(); + } + }); + + it.each(["plan mode", "accepted-plan continuation"] as const)( + "rejects a prompt if the RPC session enters %s after startup", + async (state) => { + const { lineHandler, runtimeHost, cleanup } = await startRpcMode({ withAuth: true, responseDelayMs: 0 }); + + try { + const session = runtimeHost.session; + session.setCollaborationMode("plan"); + if (state === "accepted-plan continuation") { + const submitPlan = session.agent.state.tools.find((tool) => tool.name === SUBMIT_PLAN_TOOL_NAME); + if (!submitPlan) throw new Error("Expected submit_plan in plan mode"); + await submitPlan.execute("rpc-late-plan", { + title: "Late accepted plan", + markdown: "1. Do not start through RPC.", + }); + const plan = session.pendingPlan; + if (!plan) throw new Error("Expected a pending plan"); + session.resolvePlan(plan.id, "implement"); + } + lineHandler(JSON.stringify({ id: "late-plan", type: "prompt", message: "Do not run" })); + + await vi.waitFor(() => { + expect(getPromptResponses(rpcIo.outputLines, "late-plan")).toEqual([ + expect.objectContaining({ + success: false, + error: "Plan mode is not supported in print, JSON, or RPC mode.", + }), + ]); + }); + expect(runtimeHost.prompt).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } + }, + ); + it("emits one success response when prompt is queued during streaming", async () => { const { lineHandler, cleanup } = await startRpcMode({ withAuth: true, responseDelayMs: 100 });