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}
+
+
${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 });