refactor: remove terminal agent concept entirely
Remove the "agent can be a terminal" branching from the entire codebase. An agent is now always a session-backed chat agent. Standalone terminal infrastructure (terminal component, ANSI handling, terminal-stream-protocol) is preserved. Server: delete ManagedTerminalAgent, AgentKind, TerminalExitDetails, launchTerminalAgent, registerTerminalAgent, handleTerminalAgentExited, supportsTerminalMode capability, buildTerminalCreate/ResumeCommand from all providers, terminal agent persistence/projections. App: delete terminal-agent-panel.tsx, terminal-agent-reopen-store.ts, terminal/terminalExit fields on agent state, "Terminal Agents" launcher section, terminal-agent workspace setup flow, terminal badge in agent list. CLI: remove terminal column from ls, terminal-agent error from send. 60 files changed, -3592 lines
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -9,11 +9,8 @@ import { DbAgentSnapshotStore } from "../db/db-agent-snapshot-store.js";
|
||||
import { DbAgentTimelineStore } from "../db/db-agent-timeline-store.js";
|
||||
import { openPaseoDatabase, type PaseoDatabaseHandle } from "../db/sqlite-database.js";
|
||||
import { projects, workspaces } from "../db/schema.js";
|
||||
import { AgentManager, type AgentManagerEvent } from "./agent-manager.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { createTerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import type { TerminalExitInfo, TerminalSession } from "../../terminal/terminal.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
@@ -41,47 +38,6 @@ function deferred<T>(): Deferred<T> {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
class EventPushable<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = [];
|
||||
private resolvers: Array<(value: IteratorResult<T, void>) => void> = [];
|
||||
private closed = false;
|
||||
|
||||
push(value: T): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
const resolver = this.resolvers.shift();
|
||||
if (resolver) {
|
||||
resolver({ value, done: false });
|
||||
return;
|
||||
}
|
||||
this.queue.push(value);
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.closed = true;
|
||||
while (this.resolvers.length > 0) {
|
||||
const resolver = this.resolvers.shift();
|
||||
resolver?.({ value: undefined, done: true });
|
||||
}
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<T> {
|
||||
return {
|
||||
next: () => {
|
||||
if (this.queue.length > 0) {
|
||||
const value = this.queue.shift()!;
|
||||
return Promise.resolve({ value, done: false });
|
||||
}
|
||||
if (this.closed) {
|
||||
return Promise.resolve({ value: undefined, done: true });
|
||||
}
|
||||
return new Promise((resolve) => this.resolvers.push(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
@@ -89,12 +45,6 @@ const TEST_CAPABILITIES = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: false,
|
||||
} as const;
|
||||
|
||||
const TERMINAL_TEST_CAPABILITIES = {
|
||||
...TEST_CAPABILITIES,
|
||||
supportsTerminalMode: true,
|
||||
} as const;
|
||||
|
||||
async function seedWorkspace(
|
||||
@@ -354,596 +304,9 @@ class StreamingAssistantClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
class TerminalTestAgentClient extends TestAgentClient {
|
||||
override readonly capabilities = TERMINAL_TEST_CAPABILITIES;
|
||||
public lastTerminalCreateHandle: AgentPersistenceHandle | null = null;
|
||||
public lastTerminalInitialPrompt: string | undefined;
|
||||
|
||||
override buildTerminalCreateCommand(
|
||||
_config: AgentSessionConfig,
|
||||
handle: AgentPersistenceHandle,
|
||||
initialPrompt?: string,
|
||||
) {
|
||||
this.lastTerminalCreateHandle = handle;
|
||||
this.lastTerminalInitialPrompt = initialPrompt;
|
||||
return {
|
||||
command: "terminal-test-cli",
|
||||
args: ["--session-id", handle.sessionId],
|
||||
env: { TEST_SESSION_ID: handle.sessionId },
|
||||
};
|
||||
}
|
||||
|
||||
override buildTerminalResumeCommand(handle: AgentPersistenceHandle) {
|
||||
return {
|
||||
command: "terminal-test-cli",
|
||||
args: ["resume", handle.nativeHandle ?? handle.sessionId],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createStubTerminalManager(): TerminalManager {
|
||||
const terminals = new Map<
|
||||
string,
|
||||
TerminalSession & {
|
||||
emitExit: (info?: TerminalExitInfo) => void;
|
||||
emitTitleChange: (title?: string) => void;
|
||||
}
|
||||
>();
|
||||
return {
|
||||
async getTerminals() {
|
||||
return Array.from(terminals.values());
|
||||
},
|
||||
async createTerminal(options) {
|
||||
const id = options.id ?? `term-${terminals.size + 1}`;
|
||||
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
|
||||
const titleListeners = new Set<(title?: string) => void>();
|
||||
let title: string | undefined;
|
||||
let exitInfo: TerminalExitInfo | null = null;
|
||||
const session: TerminalSession & {
|
||||
emitExit: (info?: TerminalExitInfo) => void;
|
||||
emitTitleChange: (title?: string) => void;
|
||||
} = {
|
||||
id,
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {},
|
||||
subscribe: () => () => {},
|
||||
onExit(listener) {
|
||||
exitListeners.add(listener);
|
||||
return () => {
|
||||
exitListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
onTitleChange(listener) {
|
||||
titleListeners.add(listener);
|
||||
return () => {
|
||||
titleListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getSize: () => ({ rows: 24, cols: 80 }),
|
||||
getState: () => ({ rows: 24, cols: 80, cursor: { row: 0, col: 0 }, scrollback: [], grid: [] }),
|
||||
getTitle() {
|
||||
return title;
|
||||
},
|
||||
getExitInfo() {
|
||||
return exitInfo;
|
||||
},
|
||||
kill() {
|
||||
for (const listener of Array.from(exitListeners)) {
|
||||
listener(exitInfo ?? { exitCode: null, signal: null, lastOutputLines: [] });
|
||||
}
|
||||
},
|
||||
emitExit(info = { exitCode: null, signal: null, lastOutputLines: [] }) {
|
||||
exitInfo = info;
|
||||
for (const listener of Array.from(exitListeners)) {
|
||||
listener(info);
|
||||
}
|
||||
},
|
||||
emitTitleChange(nextTitle) {
|
||||
title = nextTitle;
|
||||
for (const listener of Array.from(titleListeners)) {
|
||||
listener(nextTitle);
|
||||
}
|
||||
},
|
||||
};
|
||||
terminals.set(id, session);
|
||||
return session;
|
||||
},
|
||||
registerCwdEnv() {},
|
||||
getTerminal(id) {
|
||||
return terminals.get(id);
|
||||
},
|
||||
killTerminal(id) {
|
||||
terminals.get(id)?.kill();
|
||||
terminals.delete(id);
|
||||
},
|
||||
listDirectories() {
|
||||
return [];
|
||||
},
|
||||
killAll() {
|
||||
terminals.clear();
|
||||
},
|
||||
subscribeTerminalsChanged() {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentManager", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("terminal agents persist a deterministic handle and expose terminal kind after unload", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new TerminalTestAgentClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
terminalManager: createStubTerminalManager(),
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-0000000073e1",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
expect(snapshot.terminal).toBe(true);
|
||||
expect(snapshot.persistence).toMatchObject({
|
||||
provider: "codex",
|
||||
sessionId: "00000000-0000-4000-8000-0000000073e1",
|
||||
nativeHandle: "00000000-0000-4000-8000-0000000073e1",
|
||||
});
|
||||
expect(client.lastTerminalCreateHandle?.sessionId).toBe("00000000-0000-4000-8000-0000000073e1");
|
||||
expect(client.lastTerminalInitialPrompt).toBeUndefined();
|
||||
expect(await manager.getAgentKind(snapshot.id)).toBe("terminal");
|
||||
|
||||
await manager.closeAgent(snapshot.id);
|
||||
|
||||
expect(await manager.getAgentKind(snapshot.id)).toBe("terminal");
|
||||
expect(await manager.getStructuredSendRejection(snapshot.id)).toBe(
|
||||
"Terminal agents do not support structured send operations",
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal agents reserve the terminal binding before terminal creation completes", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-binding-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new TerminalTestAgentClient();
|
||||
let manager: AgentManager;
|
||||
const terminalManager: TerminalManager = {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
},
|
||||
async createTerminal(options) {
|
||||
expect(options.id).toBeTruthy();
|
||||
expect(manager.isTerminalBoundToAgent(options.id!)).toBe(true);
|
||||
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
|
||||
return {
|
||||
id: options.id!,
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {},
|
||||
subscribe: () => () => {},
|
||||
onExit(listener) {
|
||||
exitListeners.add(listener);
|
||||
return () => {
|
||||
exitListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getSize: () => ({ rows: 24, cols: 80 }),
|
||||
getState: () => ({ rows: 24, cols: 80, cursor: { row: 0, col: 0 }, scrollback: [], grid: [] }),
|
||||
getTitle: () => undefined,
|
||||
getExitInfo: () => null,
|
||||
onTitleChange: () => () => {},
|
||||
kill() {
|
||||
for (const listener of Array.from(exitListeners)) {
|
||||
listener({ exitCode: null, signal: null, lastOutputLines: [] });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
registerCwdEnv() {},
|
||||
getTerminal() {
|
||||
return undefined;
|
||||
},
|
||||
killTerminal() {},
|
||||
listDirectories() {
|
||||
return [];
|
||||
},
|
||||
killAll() {},
|
||||
subscribeTerminalsChanged() {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
terminalManager,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-0000000b01d0",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
expect(snapshot.terminalId).toBeTruthy();
|
||||
expect(manager.isTerminalBoundToAgent(snapshot.terminalId!)).toBe(true);
|
||||
});
|
||||
|
||||
test("setTitle persists and emits state for live terminal agents", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-title-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TerminalTestAgentClient() },
|
||||
registry: storage,
|
||||
terminalManager: createStubTerminalManager(),
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-00000000aa11",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
terminal: true,
|
||||
});
|
||||
let stateEventCount = 0;
|
||||
const unsubscribe = manager.subscribe((event) => {
|
||||
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
||||
stateEventCount += 1;
|
||||
}
|
||||
}, { agentId: snapshot.id, replayState: false });
|
||||
|
||||
await manager.setTitle(snapshot.id, "Agent Shell");
|
||||
|
||||
const stored = await storage.get(snapshot.id);
|
||||
expect(stored?.title).toBe("Agent Shell");
|
||||
expect(manager.getAgentIdForTerminal(snapshot.terminalId!)).toBe(snapshot.id);
|
||||
expect(stateEventCount).toBe(1);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("terminal agent creation ignores title propagation before the initial snapshot is persisted", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-title-race-"));
|
||||
const dataDir = join(workdir, "db");
|
||||
const database = await openPaseoDatabase(dataDir);
|
||||
let manager: AgentManager | null = null;
|
||||
|
||||
try {
|
||||
const workspaceId = await seedWorkspace(database, { directory: workdir });
|
||||
const storage = new DbAgentSnapshotStore(database.db);
|
||||
const terminalManager: TerminalManager = {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
},
|
||||
async createTerminal(options) {
|
||||
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
|
||||
const titleListeners = new Set<(title?: string) => void>();
|
||||
const session: TerminalSession = {
|
||||
id: options.id,
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {},
|
||||
subscribe: () => () => {},
|
||||
onExit(listener) {
|
||||
exitListeners.add(listener);
|
||||
return () => {
|
||||
exitListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
onTitleChange(listener) {
|
||||
titleListeners.add(listener);
|
||||
return () => {
|
||||
titleListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getSize: () => ({ rows: 24, cols: 80 }),
|
||||
getState: () => ({
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
cursor: { row: 0, col: 0 },
|
||||
scrollback: [],
|
||||
grid: [],
|
||||
}),
|
||||
getTitle: () => "Agent Shell",
|
||||
getExitInfo: () => null,
|
||||
kill() {
|
||||
for (const listener of Array.from(exitListeners)) {
|
||||
listener({ exitCode: null, signal: null, lastOutputLines: [] });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const agentId = manager?.getAgentIdForTerminal(options.id) ?? null;
|
||||
if (agentId) {
|
||||
await manager?.setTitle(agentId, "Agent Shell");
|
||||
}
|
||||
|
||||
return session;
|
||||
},
|
||||
registerCwdEnv() {},
|
||||
getTerminal() {
|
||||
return undefined;
|
||||
},
|
||||
killTerminal() {},
|
||||
listDirectories() {
|
||||
return [];
|
||||
},
|
||||
killAll() {},
|
||||
subscribeTerminalsChanged() {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
manager = new AgentManager({
|
||||
clients: { codex: new TerminalTestAgentClient() },
|
||||
registry: storage,
|
||||
terminalManager,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-00000000aa13",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
terminal: true,
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const stored = await storage.get(snapshot.id);
|
||||
expect(stored?.title).toBe("Agent Shell");
|
||||
} finally {
|
||||
await database.close();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal agent creation preserves titles propagated during terminal registration", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-registration-title-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const scriptPath = join(workdir, "npm-cli.js");
|
||||
let manager: AgentManager | null = null;
|
||||
|
||||
const terminalManager = createTerminalManager({
|
||||
resolveAgentIdForTerminal: (terminalId) => manager?.getAgentIdForTerminal(terminalId) ?? null,
|
||||
onAgentBoundTerminalTitleChange: async ({ agentId, title }) => {
|
||||
if (!manager) {
|
||||
return;
|
||||
}
|
||||
await manager.setTitle(agentId, title);
|
||||
},
|
||||
});
|
||||
|
||||
class TitleReplayTerminalAgentClient extends TerminalTestAgentClient {
|
||||
override buildTerminalCreateCommand(
|
||||
_config: AgentSessionConfig,
|
||||
handle: AgentPersistenceHandle,
|
||||
) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [scriptPath, "--session-id", handle.sessionId],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
manager = new AgentManager({
|
||||
clients: { codex: new TitleReplayTerminalAgentClient() },
|
||||
registry: storage,
|
||||
terminalManager,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-00000000aa12",
|
||||
});
|
||||
|
||||
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 2000;
|
||||
let storedTitle: string | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
storedTitle = (await storage.get(snapshot.id))?.title ?? null;
|
||||
if (storedTitle?.startsWith("npm --session-id ")) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
expect(storedTitle?.startsWith("npm --session-id ")).toBe(true);
|
||||
|
||||
terminalManager.killAll();
|
||||
});
|
||||
|
||||
test("getMetricsSnapshot skips agents without in-memory timeline state", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-metrics-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TerminalTestAgentClient() },
|
||||
registry: storage,
|
||||
terminalManager: createStubTerminalManager(),
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-00000000aa14",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
expect(manager.getMetricsSnapshot()).toEqual({
|
||||
total: 1,
|
||||
byLifecycle: { idle: 1 },
|
||||
withActiveForegroundTurn: 0,
|
||||
timelineStats: {
|
||||
totalItems: 0,
|
||||
maxItemsPerAgent: 0,
|
||||
},
|
||||
});
|
||||
expect(snapshot.terminal).toBe(true);
|
||||
});
|
||||
|
||||
test("terminal agent closure preserves exit diagnostics for failed launches", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-exit-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TerminalTestAgentClient() },
|
||||
registry: storage,
|
||||
terminalManager: createStubTerminalManager(),
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-00000000aa12",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
const terminal = manager.getTerminalSessionForAgent(snapshot.id) as TerminalSession & {
|
||||
emitExit: (info?: TerminalExitInfo) => void;
|
||||
};
|
||||
expect(terminal).toBeTruthy();
|
||||
|
||||
let closedEvent: Extract<AgentManagerEvent, { type: "agent_state" }>["agent"] | null = null;
|
||||
manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
||||
closedEvent = event.agent;
|
||||
}
|
||||
},
|
||||
{ agentId: snapshot.id, replayState: false },
|
||||
);
|
||||
|
||||
terminal.emitExit({
|
||||
exitCode: 127,
|
||||
signal: null,
|
||||
lastOutputLines: ["gemini: command not found"],
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const stored = await storage.get(snapshot.id);
|
||||
expect(stored?.terminalExit).toEqual({
|
||||
command: "terminal-test-cli",
|
||||
message: "gemini: command not found",
|
||||
exitCode: 127,
|
||||
signal: null,
|
||||
outputLines: ["gemini: command not found"],
|
||||
});
|
||||
expect(stored?.lastError).toContain("Exit code: 127");
|
||||
});
|
||||
|
||||
expect(closedEvent?.lifecycle).toBe("closed");
|
||||
expect(closedEvent?.lastError).toContain("gemini: command not found");
|
||||
expect(closedEvent?.terminalExit).toEqual({
|
||||
command: "terminal-test-cli",
|
||||
message: "gemini: command not found",
|
||||
exitCode: 127,
|
||||
signal: null,
|
||||
outputLines: ["gemini: command not found"],
|
||||
});
|
||||
});
|
||||
|
||||
test("structured send rejection is null for managed agents", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000100",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
expect(await manager.getAgentKind(snapshot.id)).toBe("session");
|
||||
expect(await manager.getStructuredSendRejection(snapshot.id)).toBeNull();
|
||||
});
|
||||
|
||||
test("createAgent passes initialPrompt into terminal command builders", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-prompt-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new TerminalTestAgentClient();
|
||||
const terminalManager: TerminalManager = {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
},
|
||||
async createTerminal(options) {
|
||||
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
|
||||
return {
|
||||
id: options.id ?? "00000000-0000-4000-8000-00000000abcd",
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {},
|
||||
subscribe: () => () => {},
|
||||
onExit(listener) {
|
||||
exitListeners.add(listener);
|
||||
return () => {
|
||||
exitListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getSize: () => ({ rows: 24, cols: 80 }),
|
||||
getState: () => ({ rows: 24, cols: 80, cursor: { row: 0, col: 0 }, scrollback: [], grid: [] }),
|
||||
getTitle: () => undefined,
|
||||
getExitInfo: () => null,
|
||||
onTitleChange: () => () => {},
|
||||
kill() {
|
||||
for (const listener of Array.from(exitListeners)) {
|
||||
listener({ exitCode: null, signal: null, lastOutputLines: [] });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
registerCwdEnv() {},
|
||||
getTerminal() {
|
||||
return undefined;
|
||||
},
|
||||
killTerminal() {},
|
||||
listDirectories() {
|
||||
return [];
|
||||
},
|
||||
killAll() {},
|
||||
subscribeTerminalsChanged() {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
terminalManager,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-00000000abcd",
|
||||
});
|
||||
|
||||
await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
terminal: true,
|
||||
},
|
||||
undefined,
|
||||
{ initialPrompt: "Implement terminal prompt routing" },
|
||||
);
|
||||
|
||||
expect(client.lastTerminalInitialPrompt).toBe("Implement terminal prompt routing");
|
||||
});
|
||||
|
||||
test("normalizeConfig does not inject default model when omitted", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { resolve } from "node:path";
|
||||
import { stat } from "node:fs/promises";
|
||||
import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import type { TerminalExitInfo, TerminalSession } from "../../terminal/terminal.js";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
@@ -31,7 +30,6 @@ import type {
|
||||
AgentRuntimeInfo,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
TerminalCommand,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||
@@ -114,13 +112,6 @@ export type WaitForAgentStartOptions = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export interface TerminalExitDetails {
|
||||
command: string;
|
||||
message: string;
|
||||
exitCode: number | null;
|
||||
signal: number | null;
|
||||
outputLines: string[];
|
||||
}
|
||||
type AttentionState =
|
||||
| { requiresAttention: false }
|
||||
| {
|
||||
@@ -149,7 +140,6 @@ type ManagedAgentBase = {
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
terminal: boolean;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
config: AgentSessionConfig;
|
||||
runtimeInfo?: AgentRuntimeInfo;
|
||||
@@ -165,7 +155,6 @@ type ManagedAgentBase = {
|
||||
lastUserMessageAt: Date | null;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string;
|
||||
terminalExit?: TerminalExitDetails;
|
||||
attention: AttentionState;
|
||||
foregroundTurnWaiters: Set<ForegroundTurnWaiter>;
|
||||
unsubscribeSession: (() => void) | null;
|
||||
@@ -181,7 +170,6 @@ type ManagedAgentBase = {
|
||||
|
||||
type ManagedAgentWithSession = ManagedAgentBase & {
|
||||
session: AgentSession;
|
||||
terminal: false;
|
||||
};
|
||||
|
||||
type ManagedAgentInitializing = ManagedAgentWithSession & {
|
||||
@@ -211,24 +199,11 @@ type ManagedAgentClosed = ManagedAgentBase & {
|
||||
activeForegroundTurnId: null;
|
||||
};
|
||||
|
||||
type ManagedTerminalAgent = ManagedAgentBase & {
|
||||
terminal: true;
|
||||
lifecycle: "idle";
|
||||
session: null;
|
||||
activeForegroundTurnId: null;
|
||||
terminalCommand: TerminalCommand;
|
||||
terminalId: string | null;
|
||||
unsubscribeTerminalExit: (() => void) | null;
|
||||
};
|
||||
|
||||
export type AgentKind = "session" | "terminal";
|
||||
|
||||
export type ManagedAgent =
|
||||
| ManagedAgentInitializing
|
||||
| ManagedAgentIdle
|
||||
| ManagedAgentRunning
|
||||
| ManagedAgentError
|
||||
| ManagedTerminalAgent
|
||||
| ManagedAgentClosed;
|
||||
|
||||
export interface AgentMetricsSnapshot {
|
||||
@@ -247,7 +222,7 @@ type ActiveManagedAgent =
|
||||
| ManagedAgentRunning
|
||||
| ManagedAgentError;
|
||||
|
||||
type LiveManagedAgent = ActiveManagedAgent | ManagedTerminalAgent;
|
||||
type LiveManagedAgent = ActiveManagedAgent;
|
||||
|
||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||
|
||||
@@ -298,73 +273,6 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
|
||||
return Object.assign(new Error(message), { name: "AbortError" });
|
||||
}
|
||||
|
||||
function formatTerminalExitSummary(input: {
|
||||
command: string;
|
||||
exitCode: number | null;
|
||||
signal: number | null;
|
||||
outputLines: string[];
|
||||
}): string {
|
||||
const commandLabel = basename(input.command) || input.command;
|
||||
const commandNotFoundLine = input.outputLines.find((line) =>
|
||||
/command not found|not recognized|no such file or directory/i.test(line),
|
||||
);
|
||||
|
||||
if (input.exitCode === 127) {
|
||||
return commandNotFoundLine ?? `${commandLabel}: command not found`;
|
||||
}
|
||||
if (input.exitCode !== null) {
|
||||
return `${commandLabel} exited with code ${input.exitCode}.`;
|
||||
}
|
||||
if (input.signal !== null) {
|
||||
return `${commandLabel} exited with signal ${input.signal}.`;
|
||||
}
|
||||
return `${commandLabel} exited unexpectedly.`;
|
||||
}
|
||||
|
||||
function buildTerminalExitDetails(input: {
|
||||
command: string;
|
||||
exit: TerminalExitInfo;
|
||||
}): TerminalExitDetails | null {
|
||||
const outputLines = input.exit.lastOutputLines.map((line) => line.trimEnd());
|
||||
while (outputLines[0]?.length === 0) {
|
||||
outputLines.shift();
|
||||
}
|
||||
while (outputLines[outputLines.length - 1]?.length === 0) {
|
||||
outputLines.pop();
|
||||
}
|
||||
|
||||
if (input.exit.exitCode === null && input.exit.signal === null && outputLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
message: formatTerminalExitSummary({
|
||||
command: input.command,
|
||||
exitCode: input.exit.exitCode,
|
||||
signal: input.exit.signal,
|
||||
outputLines,
|
||||
}),
|
||||
exitCode: input.exit.exitCode,
|
||||
signal: input.exit.signal,
|
||||
outputLines,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTerminalExitErrorMessage(details: TerminalExitDetails): string {
|
||||
const lines = [details.message];
|
||||
if (details.exitCode !== null) {
|
||||
lines.push(`Exit code: ${details.exitCode}`);
|
||||
} else if (details.signal !== null) {
|
||||
lines.push(`Signal: ${details.signal}`);
|
||||
}
|
||||
if (details.outputLines.length > 0) {
|
||||
lines.push("Last output:");
|
||||
lines.push(...details.outputLines);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function validateAgentId(agentId: string, source: string): string {
|
||||
const result = AgentIdSchema.safeParse(agentId);
|
||||
if (!result.success) {
|
||||
@@ -396,14 +304,12 @@ export class AgentManager {
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private logger: Logger;
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
|
||||
constructor(options: AgentManagerOptions) {
|
||||
this.idFactory = options?.idFactory ?? (() => randomUUID());
|
||||
this.registry = options?.registry;
|
||||
this.durableTimelineStore = options?.durableTimelineStore;
|
||||
this.onAgentAttention = options?.onAgentAttention;
|
||||
this.terminalManager = options?.terminalManager ?? null;
|
||||
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
||||
if (options?.clients) {
|
||||
for (const [provider, client] of Object.entries(options.clients)) {
|
||||
@@ -624,51 +530,6 @@ export class AgentManager {
|
||||
return agent ? { ...agent } : null;
|
||||
}
|
||||
|
||||
async getAgentKind(id: string): Promise<AgentKind | null> {
|
||||
const normalizedId = validateAgentId(id, "getAgentKind");
|
||||
const liveAgent = this.agents.get(normalizedId);
|
||||
if (liveAgent) {
|
||||
return liveAgent.terminal ? "terminal" : "session";
|
||||
}
|
||||
if (!this.registry) {
|
||||
return null;
|
||||
}
|
||||
const stored = await this.registry.get(normalizedId);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
return stored.config?.terminal === true ? "terminal" : "session";
|
||||
}
|
||||
|
||||
async getStructuredSendRejection(id: string): Promise<string | null> {
|
||||
const kind = await this.getAgentKind(id);
|
||||
return kind === "terminal"
|
||||
? "Terminal agents do not support structured send operations"
|
||||
: null;
|
||||
}
|
||||
|
||||
getTerminalSessionForAgent(id: string): TerminalSession | null {
|
||||
const agent = this.agents.get(id);
|
||||
if (!agent || !agent.terminal || !("terminalId" in agent) || !agent.terminalId) {
|
||||
return null;
|
||||
}
|
||||
return this.terminalManager?.getTerminal(agent.terminalId) ?? null;
|
||||
}
|
||||
|
||||
getAgentIdForTerminal(terminalId: string): string | null {
|
||||
for (const agent of this.agents.values()) {
|
||||
if (!agent.terminal || !("terminalId" in agent) || agent.terminalId !== terminalId) {
|
||||
continue;
|
||||
}
|
||||
return agent.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
isTerminalBoundToAgent(terminalId: string): boolean {
|
||||
return this.getAgentIdForTerminal(terminalId) !== null;
|
||||
}
|
||||
|
||||
getTimeline(id: string): AgentTimelineItem[] {
|
||||
this.requireAgent(id);
|
||||
return this.timelineStore.getItems(id);
|
||||
@@ -713,34 +574,6 @@ export class AgentManager {
|
||||
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
|
||||
);
|
||||
}
|
||||
if (normalizedConfig.terminal) {
|
||||
const buildCommand = client.buildTerminalCreateCommand;
|
||||
if (!buildCommand) {
|
||||
throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`);
|
||||
}
|
||||
const persistence = this.buildTerminalPersistenceHandle(
|
||||
resolvedAgentId,
|
||||
normalizedConfig.provider,
|
||||
normalizedConfig.cwd,
|
||||
);
|
||||
const command = buildCommand.call(
|
||||
client,
|
||||
normalizedConfig,
|
||||
persistence,
|
||||
options?.initialPrompt,
|
||||
);
|
||||
return this.registerTerminalAgent(
|
||||
resolvedAgentId,
|
||||
normalizedConfig,
|
||||
client.capabilities,
|
||||
command,
|
||||
persistence,
|
||||
{
|
||||
labels: options?.labels,
|
||||
workspaceId: options?.workspaceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
const session = await client.createSession(normalizedConfig, launchContext);
|
||||
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
|
||||
labels: options?.labels,
|
||||
@@ -748,91 +581,6 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Reconstruct an agent from provider persistence. When a durable timeline
|
||||
// store is configured, the live timeline buffer only seeds seq metadata from
|
||||
// the durable store instead of loading committed history back into memory.
|
||||
// Tests without a durable timeline store can still call
|
||||
// hydrateTimelineFromProvider() for backward compatibility.
|
||||
async launchTerminalAgent(
|
||||
config: AgentSessionConfig,
|
||||
agentId: string,
|
||||
options?: {
|
||||
persistence?: AgentPersistenceHandle | null;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
attention?: {
|
||||
requiresAttention: boolean;
|
||||
attentionReason?: "finished" | "error" | "permission" | null;
|
||||
attentionTimestamp?: Date | null;
|
||||
};
|
||||
},
|
||||
): Promise<ManagedTerminalAgent> {
|
||||
const resolvedAgentId = validateAgentId(agentId, "launchTerminalAgent");
|
||||
const normalizedConfig = await this.normalizeConfig(config);
|
||||
const client = this.requireClient(normalizedConfig.provider);
|
||||
const available = await client.isAvailable();
|
||||
if (!available) {
|
||||
throw new Error(
|
||||
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
|
||||
);
|
||||
}
|
||||
|
||||
const resumeCommand =
|
||||
options?.persistence && client.buildTerminalResumeCommand
|
||||
? client.buildTerminalResumeCommand.call(client, options.persistence)
|
||||
: null;
|
||||
const createCommand = client.buildTerminalCreateCommand;
|
||||
const terminalCommand =
|
||||
resumeCommand ??
|
||||
(createCommand
|
||||
? createCommand.call(
|
||||
client,
|
||||
normalizedConfig,
|
||||
options?.persistence ??
|
||||
this.buildTerminalPersistenceHandle(
|
||||
resolvedAgentId,
|
||||
normalizedConfig.provider,
|
||||
normalizedConfig.cwd,
|
||||
),
|
||||
)
|
||||
: null);
|
||||
|
||||
if (!terminalCommand) {
|
||||
throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`);
|
||||
}
|
||||
|
||||
return this.registerTerminalAgent(
|
||||
resolvedAgentId,
|
||||
normalizedConfig,
|
||||
client.capabilities,
|
||||
terminalCommand,
|
||||
options?.persistence ??
|
||||
this.buildTerminalPersistenceHandle(
|
||||
resolvedAgentId,
|
||||
normalizedConfig.provider,
|
||||
normalizedConfig.cwd,
|
||||
),
|
||||
{
|
||||
createdAt: options?.createdAt,
|
||||
updatedAt: options?.updatedAt,
|
||||
lastUserMessageAt: options?.lastUserMessageAt,
|
||||
labels: options?.labels,
|
||||
attention:
|
||||
options?.attention?.requiresAttention &&
|
||||
options.attention.attentionReason &&
|
||||
options.attention.attentionTimestamp
|
||||
? {
|
||||
requiresAttention: true,
|
||||
attentionReason: options.attention.attentionReason,
|
||||
attentionTimestamp: options.attention.attentionTimestamp,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Reconstruct an agent from provider persistence. Callers should explicitly
|
||||
// hydrate timeline history after resume.
|
||||
async resumeAgentFromPersistence(
|
||||
@@ -941,13 +689,7 @@ export class AgentManager {
|
||||
"closeAgent: start",
|
||||
);
|
||||
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
|
||||
if (agent.terminal) {
|
||||
if (agent.terminalId) {
|
||||
this.terminalManager?.killTerminal(agent.terminalId);
|
||||
}
|
||||
} else {
|
||||
await agent.session.close();
|
||||
}
|
||||
await agent.session.close();
|
||||
this.timelineStore.delete(agentId);
|
||||
this.emitClosedAgent(closedAgent);
|
||||
this.logger.trace({ agentId }, "closeAgent: completed");
|
||||
@@ -2050,7 +1792,6 @@ export class AgentManager {
|
||||
id: resolvedAgentId,
|
||||
provider: config.provider,
|
||||
cwd: config.cwd,
|
||||
terminal: false,
|
||||
session,
|
||||
capabilities: session.capabilities,
|
||||
config,
|
||||
@@ -2071,7 +1812,6 @@ export class AgentManager {
|
||||
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
||||
lastUsage: options?.lastUsage,
|
||||
lastError: options?.lastError,
|
||||
terminalExit: undefined,
|
||||
attention:
|
||||
options?.attention != null
|
||||
? options.attention.requiresAttention
|
||||
@@ -2118,162 +1858,6 @@ export class AgentManager {
|
||||
};
|
||||
}
|
||||
|
||||
private async registerTerminalAgent(
|
||||
agentId: string,
|
||||
config: AgentSessionConfig,
|
||||
capabilities: AgentCapabilityFlags,
|
||||
terminalCommand: TerminalCommand,
|
||||
persistence: AgentPersistenceHandle,
|
||||
options?: {
|
||||
workspaceId?: number;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
labels?: Record<string, string>;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
): Promise<ManagedTerminalAgent> {
|
||||
if (!this.terminalManager) {
|
||||
throw new Error("Terminal manager is not configured");
|
||||
}
|
||||
const resolvedAgentId = validateAgentId(agentId, "registerTerminalAgent");
|
||||
if (this.agents.has(resolvedAgentId)) {
|
||||
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
|
||||
}
|
||||
const initialPersistedTitle = await this.resolveInitialPersistedTitle(resolvedAgentId, config);
|
||||
const now = new Date();
|
||||
const reservedTerminalId = randomUUID();
|
||||
|
||||
const managed: ManagedTerminalAgent = {
|
||||
id: resolvedAgentId,
|
||||
provider: config.provider,
|
||||
cwd: config.cwd,
|
||||
terminal: true,
|
||||
session: null,
|
||||
capabilities,
|
||||
config,
|
||||
runtimeInfo: undefined,
|
||||
lifecycle: "idle",
|
||||
createdAt: options?.createdAt ?? now,
|
||||
updatedAt: options?.updatedAt ?? now,
|
||||
availableModes: [],
|
||||
currentModeId: config.modeId ?? null,
|
||||
pendingPermissions: new Map<string, AgentPermissionRequest>(),
|
||||
pendingReplacement: false,
|
||||
activeForegroundTurnId: null,
|
||||
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
|
||||
unsubscribeSession: null,
|
||||
provisionalAssistantText: null,
|
||||
persistence: attachPersistenceCwd(persistence, config.cwd),
|
||||
historyPrimed: false,
|
||||
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
||||
lastUsage: undefined,
|
||||
lastError: undefined,
|
||||
terminalExit: undefined,
|
||||
attention:
|
||||
options?.attention != null
|
||||
? options.attention.requiresAttention
|
||||
? {
|
||||
requiresAttention: true,
|
||||
attentionReason: options.attention.attentionReason,
|
||||
attentionTimestamp: new Date(options.attention.attentionTimestamp),
|
||||
}
|
||||
: { requiresAttention: false }
|
||||
: { requiresAttention: false },
|
||||
internal: config.internal ?? false,
|
||||
labels: options?.labels ?? {},
|
||||
terminalCommand,
|
||||
terminalId: reservedTerminalId,
|
||||
unsubscribeTerminalExit: null,
|
||||
};
|
||||
|
||||
this.agents.set(resolvedAgentId, managed);
|
||||
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
|
||||
this.agentsAwaitingInitialSnapshotPersist.add(resolvedAgentId);
|
||||
|
||||
let terminalSession: TerminalSession;
|
||||
try {
|
||||
terminalSession = await this.terminalManager.createTerminal({
|
||||
id: reservedTerminalId,
|
||||
cwd: config.cwd,
|
||||
name: initialPersistedTitle ?? undefined,
|
||||
command: terminalCommand.command,
|
||||
args: terminalCommand.args,
|
||||
env: terminalCommand.env,
|
||||
});
|
||||
} catch (error) {
|
||||
this.agents.delete(resolvedAgentId);
|
||||
this.previousStatuses.delete(resolvedAgentId);
|
||||
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (terminalSession.id !== reservedTerminalId) {
|
||||
this.agents.delete(resolvedAgentId);
|
||||
this.previousStatuses.delete(resolvedAgentId);
|
||||
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
|
||||
throw new Error(
|
||||
`Reserved terminal id ${reservedTerminalId} but terminal manager returned ${terminalSession.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const unsubscribeTerminalExit = terminalSession.onExit((exit) => {
|
||||
void this.handleTerminalAgentExited(resolvedAgentId, exit);
|
||||
});
|
||||
managed.unsubscribeTerminalExit = unsubscribeTerminalExit;
|
||||
const terminalSessionTitle = terminalSession.getTitle()?.trim();
|
||||
try {
|
||||
await this.persistSnapshot(managed, {
|
||||
workspaceId: options?.workspaceId,
|
||||
title:
|
||||
terminalSessionTitle && terminalSessionTitle.length > 0
|
||||
? terminalSessionTitle
|
||||
: initialPersistedTitle,
|
||||
});
|
||||
} finally {
|
||||
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
|
||||
}
|
||||
this.emitState(managed);
|
||||
return { ...managed };
|
||||
}
|
||||
|
||||
private async handleTerminalAgentExited(agentId: string, exit: TerminalExitInfo): Promise<void> {
|
||||
const agent = this.agents.get(agentId);
|
||||
if (!agent || !agent.terminal) {
|
||||
return;
|
||||
}
|
||||
const terminalExit = buildTerminalExitDetails({
|
||||
command: agent.terminalCommand.command,
|
||||
exit,
|
||||
});
|
||||
if (terminalExit) {
|
||||
agent.terminalExit = terminalExit;
|
||||
if (terminalExit.exitCode !== null && terminalExit.exitCode !== 0) {
|
||||
agent.lastError = buildTerminalExitErrorMessage(terminalExit);
|
||||
} else if (terminalExit.signal !== null) {
|
||||
agent.lastError = buildTerminalExitErrorMessage(terminalExit);
|
||||
}
|
||||
}
|
||||
const closedAgent = this.prepareAgentForClosure(agent, "agent terminal exited");
|
||||
await this.persistSnapshot(closedAgent);
|
||||
this.emitClosedAgent(closedAgent);
|
||||
}
|
||||
|
||||
private buildTerminalPersistenceHandle(
|
||||
agentId: string,
|
||||
provider: AgentProvider,
|
||||
cwd: string,
|
||||
): AgentPersistenceHandle {
|
||||
return attachPersistenceCwd(
|
||||
{
|
||||
provider,
|
||||
sessionId: agentId,
|
||||
nativeHandle: agentId,
|
||||
},
|
||||
cwd,
|
||||
)!;
|
||||
}
|
||||
|
||||
private prepareAgentForClosure(
|
||||
agent: LiveManagedAgent,
|
||||
cancelReason: string,
|
||||
@@ -2284,10 +1868,6 @@ export class AgentManager {
|
||||
agent.unsubscribeSession();
|
||||
agent.unsubscribeSession = null;
|
||||
}
|
||||
if (agent.terminal && agent.unsubscribeTerminalExit) {
|
||||
agent.unsubscribeTerminalExit();
|
||||
agent.unsubscribeTerminalExit = null;
|
||||
}
|
||||
for (const waiter of agent.foregroundTurnWaiters) {
|
||||
waiter.callback({
|
||||
type: "turn_canceled",
|
||||
@@ -2330,7 +1910,7 @@ export class AgentManager {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
if (current.terminal || current.session == null) {
|
||||
if (current.session == null) {
|
||||
return;
|
||||
}
|
||||
await this.dispatchSessionEvent(current, event);
|
||||
@@ -3112,8 +2692,8 @@ export class AgentManager {
|
||||
|
||||
private requireSessionAgent(id: string): ActiveManagedAgent {
|
||||
const agent = this.requireAgent(id);
|
||||
if (agent.terminal || agent.session === null) {
|
||||
throw new Error(`Agent '${agent.id}' is a terminal agent and has no managed session`);
|
||||
if (agent.session === null) {
|
||||
throw new Error(`Agent '${agent.id}' has no managed session`);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
config: { ...baseConfig, ...configOverrides },
|
||||
lifecycle,
|
||||
|
||||
@@ -63,7 +63,6 @@ export function toStoredAgentRecord(
|
||||
runtimeInfo,
|
||||
persistence,
|
||||
lastError: agent.lastError ?? undefined,
|
||||
terminalExit: agent.terminalExit ?? undefined,
|
||||
requiresAttention: agent.attention.requiresAttention,
|
||||
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
|
||||
attentionTimestamp: agent.attention.requiresAttention
|
||||
@@ -88,7 +87,6 @@ export function toAgentPayload(
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
terminal: agent.terminal,
|
||||
model: agent.config.model ?? null,
|
||||
thinkingOptionId,
|
||||
effectiveThinkingOptionId,
|
||||
@@ -115,10 +113,6 @@ export function toAgentPayload(
|
||||
payload.lastError = agent.lastError;
|
||||
}
|
||||
|
||||
if (agent.terminalExit) {
|
||||
payload.terminalExit = agent.terminalExit;
|
||||
}
|
||||
|
||||
// Handle attention state
|
||||
payload.requiresAttention = agent.attention.requiresAttention;
|
||||
if (agent.attention.requiresAttention) {
|
||||
@@ -134,9 +128,6 @@ export function toAgentPayload(
|
||||
|
||||
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
|
||||
const serializable: SerializableAgentConfig = {};
|
||||
if (config.terminal !== undefined) {
|
||||
serializable.terminal = config.terminal;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(config, "title")) {
|
||||
serializable.title = config.title ?? null;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ export type AgentCapabilityFlags = {
|
||||
supportsMcpServers: boolean;
|
||||
supportsReasoningStream: boolean;
|
||||
supportsToolInvocations: boolean;
|
||||
supportsTerminalMode: boolean;
|
||||
};
|
||||
|
||||
export type AgentPersistenceHandle = {
|
||||
@@ -358,16 +357,9 @@ export type PersistedAgentDescriptor = {
|
||||
timeline: AgentTimelineItem[];
|
||||
};
|
||||
|
||||
export type TerminalCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AgentSessionConfig = {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
terminal?: boolean;
|
||||
/**
|
||||
* Provider-agnostic system/developer instruction string.
|
||||
* Mapped by each provider to its native instruction field.
|
||||
@@ -437,12 +429,6 @@ export interface AgentClient {
|
||||
): Promise<AgentSession>;
|
||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
buildTerminalCreateCommand?(
|
||||
config: AgentSessionConfig,
|
||||
handle: AgentPersistenceHandle,
|
||||
initialPrompt?: string,
|
||||
): TerminalCommand;
|
||||
buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand;
|
||||
/**
|
||||
* Check if this provider is available (CLI binary is installed).
|
||||
* Returns true if available, false otherwise.
|
||||
|
||||
@@ -57,7 +57,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
config,
|
||||
lifecycle,
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
|
||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||
.object({
|
||||
terminal: z.boolean().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
modeId: z.string().nullable().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
@@ -59,15 +58,6 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
.optional(),
|
||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||
lastError: z.string().nullable().optional(),
|
||||
terminalExit: z
|
||||
.object({
|
||||
command: z.string(),
|
||||
message: z.string(),
|
||||
exitCode: z.number().nullable(),
|
||||
signal: z.number().nullable(),
|
||||
outputLines: z.array(z.string()),
|
||||
})
|
||||
.optional(),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
attentionTimestamp: z.string().nullable().optional(),
|
||||
@@ -77,7 +67,6 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
|
||||
export type SerializableAgentConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
| "terminal"
|
||||
| "title"
|
||||
| "modeId"
|
||||
| "model"
|
||||
|
||||
@@ -125,21 +125,21 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
{
|
||||
id: "gemini",
|
||||
label: "Gemini CLI",
|
||||
description: "Google's terminal-based coding agent",
|
||||
description: "Google's coding agent CLI",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
{
|
||||
id: "amp",
|
||||
label: "AMP",
|
||||
description: "Sourcegraph's terminal-based coding agent",
|
||||
description: "Sourcegraph's coding agent CLI",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
{
|
||||
id: "aider",
|
||||
label: "Aider",
|
||||
description: "Paul Gauthier's terminal-based coding assistant",
|
||||
description: "Paul Gauthier's coding assistant CLI",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
|
||||
@@ -9,14 +9,10 @@ import type {
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
ListModelsOptions,
|
||||
TerminalCommand,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
isProviderCommandAvailable,
|
||||
resolveProviderCommandPrefix,
|
||||
sanitizeTerminalEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
|
||||
@@ -29,11 +25,8 @@ const AIDER_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: true,
|
||||
};
|
||||
|
||||
type AiderAgentConfig = AgentSessionConfig & { provider: "aider" };
|
||||
|
||||
function resolveAiderBinary(): string {
|
||||
const found = findExecutable("aider");
|
||||
if (found) {
|
||||
@@ -45,7 +38,7 @@ function resolveAiderBinary(): string {
|
||||
}
|
||||
|
||||
function createUnsupportedSessionError(): Error {
|
||||
return new Error("Aider currently supports terminal mode only in Paseo.");
|
||||
return new Error("Aider does not support session-backed agents in Paseo.");
|
||||
}
|
||||
|
||||
export class AiderAgentClient implements AgentClient {
|
||||
@@ -73,38 +66,10 @@ export class AiderAgentClient implements AgentClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
buildTerminalCreateCommand(
|
||||
config: AgentSessionConfig,
|
||||
_handle: AgentPersistenceHandle,
|
||||
_initialPrompt?: string,
|
||||
): TerminalCommand {
|
||||
this.assertConfig(config);
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveAiderBinary,
|
||||
);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
// Aider uses positional arguments for file paths, not interactive prompts.
|
||||
args: [...launchPrefix.args, "--no-auto-commits"],
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (this.runtimeSettings?.command?.mode === "replace") {
|
||||
return existsSync(this.runtimeSettings.command.argv[0]);
|
||||
}
|
||||
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAiderBinary);
|
||||
}
|
||||
|
||||
private assertConfig(config: AgentSessionConfig): AiderAgentConfig {
|
||||
if (config.provider !== AIDER_PROVIDER) {
|
||||
throw new Error(`AiderAgentClient received config for provider '${config.provider}'`);
|
||||
}
|
||||
return { ...config, provider: AIDER_PROVIDER };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,10 @@ import type {
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
ListModelsOptions,
|
||||
TerminalCommand,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
isProviderCommandAvailable,
|
||||
resolveProviderCommandPrefix,
|
||||
sanitizeTerminalEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
|
||||
@@ -29,11 +25,8 @@ const AMP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: true,
|
||||
};
|
||||
|
||||
type AmpAgentConfig = AgentSessionConfig & { provider: "amp" };
|
||||
|
||||
function resolveAmpBinary(): string {
|
||||
const found = findExecutable("amp");
|
||||
if (found) {
|
||||
@@ -45,7 +38,7 @@ function resolveAmpBinary(): string {
|
||||
}
|
||||
|
||||
function createUnsupportedSessionError(): Error {
|
||||
return new Error("AMP currently supports terminal mode only in Paseo.");
|
||||
return new Error("AMP does not support session-backed agents in Paseo.");
|
||||
}
|
||||
|
||||
export class AmpAgentClient implements AgentClient {
|
||||
@@ -73,37 +66,10 @@ export class AmpAgentClient implements AgentClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
buildTerminalCreateCommand(
|
||||
config: AgentSessionConfig,
|
||||
_handle: AgentPersistenceHandle,
|
||||
_initialPrompt?: string,
|
||||
): TerminalCommand {
|
||||
this.assertConfig(config);
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveAmpBinary,
|
||||
);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args: [...launchPrefix.args],
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (this.runtimeSettings?.command?.mode === "replace") {
|
||||
return existsSync(this.runtimeSettings.command.argv[0]);
|
||||
}
|
||||
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAmpBinary);
|
||||
}
|
||||
|
||||
private assertConfig(config: AgentSessionConfig): AmpAgentConfig {
|
||||
if (config.provider !== AMP_PROVIDER) {
|
||||
throw new Error(`AmpAgentClient received config for provider '${config.provider}'`);
|
||||
}
|
||||
return { ...config, provider: AMP_PROVIDER };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,12 +66,10 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
TerminalCommand,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
sanitizeTerminalEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
||||
@@ -104,7 +102,6 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: true,
|
||||
};
|
||||
|
||||
const DEFAULT_MODES: AgentMode[] = [
|
||||
@@ -1099,73 +1096,6 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
buildTerminalCreateCommand(
|
||||
config: AgentSessionConfig,
|
||||
handle: AgentPersistenceHandle,
|
||||
initialPrompt?: string,
|
||||
): TerminalCommand {
|
||||
const claudeConfig = this.assertConfig(config);
|
||||
const baseCommand = findExecutable("claude") ?? "claude";
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
const spawnCommand = resolveClaudeSpawnCommand(
|
||||
{
|
||||
command: baseCommand,
|
||||
args: [],
|
||||
cwd: claudeConfig.cwd,
|
||||
env: terminalEnv,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
this.runtimeSettings,
|
||||
);
|
||||
const args = [...spawnCommand.args, "--session-id", handle.sessionId];
|
||||
if (claudeConfig.modeId === "bypassPermissions") {
|
||||
args.push("--dangerously-skip-permissions");
|
||||
} else if (claudeConfig.modeId) {
|
||||
args.push("--permission-mode", claudeConfig.modeId);
|
||||
}
|
||||
if (claudeConfig.model) {
|
||||
args.push("--model", claudeConfig.model);
|
||||
}
|
||||
if (claudeConfig.thinkingOptionId && claudeConfig.thinkingOptionId !== "default") {
|
||||
args.push("--effort", claudeConfig.thinkingOptionId);
|
||||
}
|
||||
if (claudeConfig.systemPrompt?.trim()) {
|
||||
args.push("--append-system-prompt", claudeConfig.systemPrompt.trim());
|
||||
}
|
||||
if (initialPrompt?.trim()) {
|
||||
args.push(initialPrompt.trim());
|
||||
}
|
||||
return {
|
||||
command: spawnCommand.command,
|
||||
args,
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||
const baseCommand = findExecutable("claude") ?? "claude";
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
const spawnCommand = resolveClaudeSpawnCommand(
|
||||
{
|
||||
command: baseCommand,
|
||||
args: [],
|
||||
cwd: process.cwd(),
|
||||
env: terminalEnv,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
this.runtimeSettings,
|
||||
);
|
||||
return {
|
||||
command: spawnCommand.command,
|
||||
args: [...spawnCommand.args, "--resume", handle.sessionId],
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const command = this.runtimeSettings?.command;
|
||||
if (command?.mode === "replace") {
|
||||
|
||||
@@ -19,11 +19,9 @@ import type {
|
||||
AgentTimelineItem,
|
||||
ToolCallTimelineItem,
|
||||
AgentUsage,
|
||||
AgentPersistenceHandle,
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
TerminalCommand,
|
||||
} from "../agent-sdk-types.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
@@ -45,7 +43,6 @@ import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
resolveProviderCommandPrefix,
|
||||
sanitizeTerminalEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
@@ -62,7 +59,6 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: true,
|
||||
};
|
||||
|
||||
const CODEX_MODES: AgentMode[] = [
|
||||
@@ -3536,59 +3532,6 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
buildTerminalCreateCommand(
|
||||
config: AgentSessionConfig,
|
||||
handle: AgentPersistenceHandle,
|
||||
initialPrompt?: string,
|
||||
): TerminalCommand {
|
||||
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
|
||||
const modeId = sessionConfig.modeId ?? DEFAULT_CODEX_MODE_ID;
|
||||
validateCodexMode(modeId);
|
||||
const preset = MODE_PRESETS[modeId] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
|
||||
const approvalPolicy = sessionConfig.approvalPolicy ?? preset.approvalPolicy;
|
||||
const sandbox = sessionConfig.sandboxMode ?? preset.sandbox;
|
||||
const args = [...launchPrefix.args, "-c", `sessionId=\"${handle.sessionId}\"`];
|
||||
if (sessionConfig.model) {
|
||||
args.push("--model", sessionConfig.model);
|
||||
}
|
||||
args.push("--ask-for-approval", approvalPolicy, "--sandbox", sandbox);
|
||||
if (
|
||||
typeof sessionConfig.networkAccess === "boolean"
|
||||
? sessionConfig.networkAccess
|
||||
: preset.networkAccess === true
|
||||
) {
|
||||
args.push("--search");
|
||||
}
|
||||
if (initialPrompt?.trim()) {
|
||||
args.push(initialPrompt.trim());
|
||||
}
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args,
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args: [
|
||||
...launchPrefix.args,
|
||||
"resume",
|
||||
handle.nativeHandle ?? handle.sessionId,
|
||||
],
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const child = this.spawnAppServer();
|
||||
const client = new CodexAppServerClient(child, this.logger);
|
||||
|
||||
@@ -9,14 +9,10 @@ import type {
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
ListModelsOptions,
|
||||
TerminalCommand,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
isProviderCommandAvailable,
|
||||
resolveProviderCommandPrefix,
|
||||
sanitizeTerminalEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
|
||||
@@ -29,11 +25,8 @@ const GEMINI_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: true,
|
||||
};
|
||||
|
||||
type GeminiAgentConfig = AgentSessionConfig & { provider: "gemini" };
|
||||
|
||||
function resolveGeminiBinary(): string {
|
||||
const found = findExecutable("gemini");
|
||||
if (found) {
|
||||
@@ -45,7 +38,7 @@ function resolveGeminiBinary(): string {
|
||||
}
|
||||
|
||||
function createUnsupportedSessionError(): Error {
|
||||
return new Error("Gemini CLI currently supports terminal mode only in Paseo.");
|
||||
return new Error("Gemini CLI does not support session-backed agents in Paseo.");
|
||||
}
|
||||
|
||||
export class GeminiAgentClient implements AgentClient {
|
||||
@@ -73,56 +66,10 @@ export class GeminiAgentClient implements AgentClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
buildTerminalCreateCommand(
|
||||
config: AgentSessionConfig,
|
||||
_handle: AgentPersistenceHandle,
|
||||
initialPrompt?: string,
|
||||
): TerminalCommand {
|
||||
this.assertConfig(config);
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveGeminiBinary,
|
||||
);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
const args = [...launchPrefix.args];
|
||||
if (initialPrompt?.trim()) {
|
||||
args.push("-i", initialPrompt.trim());
|
||||
}
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args,
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
buildTerminalResumeCommand(_handle: AgentPersistenceHandle): TerminalCommand {
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveGeminiBinary,
|
||||
);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args: [...launchPrefix.args, "--resume"],
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (this.runtimeSettings?.command?.mode === "replace") {
|
||||
return existsSync(this.runtimeSettings.command.argv[0]);
|
||||
}
|
||||
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveGeminiBinary);
|
||||
}
|
||||
|
||||
private assertConfig(config: AgentSessionConfig): GeminiAgentConfig {
|
||||
if (config.provider !== GEMINI_PROVIDER) {
|
||||
throw new Error(`GeminiAgentClient received config for provider '${config.provider}'`);
|
||||
}
|
||||
return { ...config, provider: GEMINI_PROVIDER };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +28,11 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
TerminalCommand,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
resolveProviderCommandPrefix,
|
||||
sanitizeTerminalEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||
@@ -46,7 +44,6 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: true,
|
||||
};
|
||||
|
||||
const DEFAULT_MODES: AgentMode[] = [
|
||||
@@ -562,53 +559,6 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
buildTerminalCreateCommand(
|
||||
config: AgentSessionConfig,
|
||||
handle: AgentPersistenceHandle,
|
||||
initialPrompt?: string,
|
||||
): TerminalCommand {
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveOpenCodeBinary,
|
||||
);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
const args = [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId];
|
||||
if (config.cwd) {
|
||||
args.push(config.cwd);
|
||||
}
|
||||
if (config.model) {
|
||||
args.push("--model", config.model);
|
||||
}
|
||||
if (config.modeId) {
|
||||
args.push("--agent", config.modeId);
|
||||
}
|
||||
if (initialPrompt?.trim()) {
|
||||
args.push(initialPrompt.trim());
|
||||
}
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args,
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveOpenCodeBinary,
|
||||
);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args: [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId],
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const command = this.runtimeSettings?.command;
|
||||
if (command?.mode === "replace") {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import { AiderAgentClient } from "./aider-agent.js";
|
||||
import { AmpAgentClient } from "./amp-agent.js";
|
||||
import { GeminiAgentClient } from "./gemini-agent.js";
|
||||
|
||||
function createExecutable(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "terminal-provider-test-"));
|
||||
const file = path.join(dir, "provider-bin");
|
||||
writeFileSync(file, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(file, 0o755);
|
||||
return file;
|
||||
}
|
||||
|
||||
const buildConfig = (provider: "gemini" | "amp" | "aider"): AgentSessionConfig => ({
|
||||
provider,
|
||||
cwd: "/tmp/worktree",
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
describe("terminal-only providers", () => {
|
||||
test("Gemini builds an interactive prompt command without injecting cwd flags", () => {
|
||||
const executable = createExecutable();
|
||||
try {
|
||||
const client = new GeminiAgentClient({
|
||||
command: { mode: "replace", argv: [executable] },
|
||||
});
|
||||
|
||||
const command = client.buildTerminalCreateCommand(
|
||||
buildConfig("gemini"),
|
||||
{ provider: "gemini", sessionId: "session-1" },
|
||||
"Fix the bug",
|
||||
);
|
||||
|
||||
expect(command.command).toBe(executable);
|
||||
expect(command.args).toEqual(["-i", "Fix the bug"]);
|
||||
} finally {
|
||||
rmSync(path.dirname(executable), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("AMP launches without unsupported cwd flags", () => {
|
||||
const executable = createExecutable();
|
||||
try {
|
||||
const client = new AmpAgentClient({
|
||||
command: { mode: "replace", argv: [executable] },
|
||||
});
|
||||
|
||||
const command = client.buildTerminalCreateCommand(buildConfig("amp"), {
|
||||
provider: "amp",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(command.command).toBe(executable);
|
||||
expect(command.args).toEqual([]);
|
||||
} finally {
|
||||
rmSync(path.dirname(executable), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Aider does not treat initial prompts as positional CLI arguments", () => {
|
||||
const executable = createExecutable();
|
||||
try {
|
||||
const client = new AiderAgentClient({
|
||||
command: { mode: "replace", argv: [executable] },
|
||||
});
|
||||
|
||||
const command = client.buildTerminalCreateCommand(
|
||||
buildConfig("aider"),
|
||||
{ provider: "aider", sessionId: "session-1" },
|
||||
"Refactor the parser",
|
||||
);
|
||||
|
||||
expect(command.command).toBe(executable);
|
||||
expect(command.args).toEqual(["--no-auto-commits"]);
|
||||
} finally {
|
||||
rmSync(path.dirname(executable), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("provider availability respects missing replacement binaries", async () => {
|
||||
const missingPath = path.join(os.tmpdir(), "missing-terminal-provider");
|
||||
|
||||
await expect(
|
||||
new GeminiAgentClient({
|
||||
command: { mode: "replace", argv: [missingPath] },
|
||||
}).isAvailable(),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
new AmpAgentClient({
|
||||
command: { mode: "replace", argv: [missingPath] },
|
||||
}).isAvailable(),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
new AiderAgentClient({
|
||||
command: { mode: "replace", argv: [missingPath] },
|
||||
}).isAvailable(),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -408,22 +408,7 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
const durableTimelineStore = new DbAgentTimelineStore(database.db);
|
||||
let agentManager: AgentManager | null = null;
|
||||
const terminalManager = createTerminalManager({
|
||||
resolveAgentIdForTerminal: (terminalId) => agentManager?.getAgentIdForTerminal(terminalId) ?? null,
|
||||
onAgentBoundTerminalTitleChange: async ({ agentId, title }) => {
|
||||
if (!agentManager) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await agentManager.setTitle(agentId, title);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ err: error, agentId },
|
||||
"Failed to propagate bound terminal title to agent state",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
const terminalManager = createTerminalManager();
|
||||
agentManager = new AgentManager({
|
||||
clients: {
|
||||
...createAllClients(logger, {
|
||||
|
||||
@@ -33,7 +33,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: false,
|
||||
};
|
||||
|
||||
interface ScriptedAgentBehavior {
|
||||
|
||||
@@ -785,7 +785,6 @@ export class LoopService {
|
||||
model: loop.workerModel ?? loop.model ?? undefined,
|
||||
title: buildWorkerTitle(loop, iteration.index),
|
||||
internal: true,
|
||||
terminal: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -796,7 +795,6 @@ export class LoopService {
|
||||
model: loop.verifierModel ?? loop.model ?? undefined,
|
||||
title: buildVerifierTitle(loop, iteration.index),
|
||||
internal: true,
|
||||
terminal: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -95,22 +95,19 @@ describe("persistence hooks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSessionConfig accepts terminal-only providers from the canonical manifest", () => {
|
||||
test("buildSessionConfig accepts providers from the canonical manifest", () => {
|
||||
const record = createRecord({
|
||||
provider: "gemini",
|
||||
persistence: {
|
||||
provider: "gemini",
|
||||
sessionId: "session-123",
|
||||
},
|
||||
config: {
|
||||
terminal: true,
|
||||
},
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(buildSessionConfig(record)).toMatchObject({
|
||||
provider: "gemini",
|
||||
cwd: "/tmp/project",
|
||||
terminal: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import { isValidAgentProvider } from "./agent/provider-manifest.js";
|
||||
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
|
||||
return {
|
||||
cwd: record.cwd,
|
||||
terminal: record.config?.terminal ?? undefined,
|
||||
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
|
||||
model: record.config?.model ?? undefined,
|
||||
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
||||
@@ -26,7 +25,6 @@ export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfi
|
||||
return {
|
||||
provider: record.provider,
|
||||
cwd: record.cwd,
|
||||
terminal: overrides.terminal,
|
||||
modeId: overrides.modeId,
|
||||
model: overrides.model,
|
||||
thinkingOptionId: overrides.thinkingOptionId,
|
||||
|
||||
@@ -368,9 +368,6 @@ export class ScheduleService {
|
||||
private async executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult> {
|
||||
if (schedule.target.type === "agent") {
|
||||
const agent = await this.ensureAgentLoaded(schedule.target.agentId);
|
||||
if (agent.terminal) {
|
||||
throw new Error(`Agent ${agent.id} is a terminal agent and cannot be targeted by schedules`);
|
||||
}
|
||||
if (this.agentManager.hasInFlightRun(agent.id)) {
|
||||
throw new Error(`Agent ${agent.id} already has an active run`);
|
||||
}
|
||||
@@ -401,7 +398,6 @@ export class ScheduleService {
|
||||
extra: schedule.target.config.extra,
|
||||
systemPrompt: schedule.target.config.systemPrompt,
|
||||
mcpServers: schedule.target.config.mcpServers as AgentSessionConfig["mcpServers"],
|
||||
terminal: false,
|
||||
};
|
||||
const labels = {
|
||||
"paseo.schedule-id": schedule.id,
|
||||
|
||||
@@ -77,7 +77,6 @@ function createSessionForOwnershipTests(options?: {
|
||||
hydrateTimelineFromProvider: vi.fn(async () => {
|
||||
throw new Error("Session should not call hydrateTimelineFromProvider directly");
|
||||
}),
|
||||
getStructuredSendRejection: vi.fn(async () => null),
|
||||
fetchTimeline: vi.fn(async () => ({
|
||||
rows: options?.timelineRows ?? [],
|
||||
hasOlder: false,
|
||||
|
||||
@@ -57,8 +57,6 @@ import {
|
||||
type VoiceTurnController,
|
||||
} from "./voice/voice-turn-controller.js";
|
||||
import {
|
||||
buildSessionConfig,
|
||||
extractTimestamps,
|
||||
toAgentPersistenceHandle,
|
||||
} from "./persistence-hooks.js";
|
||||
import { experimental_createMCPClient } from "ai";
|
||||
@@ -1005,7 +1003,6 @@ export class Session {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
} as const;
|
||||
|
||||
const createdAt = new Date(record.createdAt);
|
||||
@@ -1033,7 +1030,6 @@ export class Session {
|
||||
id: record.id,
|
||||
provider,
|
||||
cwd: record.cwd,
|
||||
terminal: record.config?.terminal ?? false,
|
||||
model: record.config?.model ?? null,
|
||||
thinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||||
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
|
||||
@@ -1052,7 +1048,6 @@ export class Session {
|
||||
persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence),
|
||||
lastUsage: undefined,
|
||||
lastError: record.lastError ?? undefined,
|
||||
terminalExit: record.terminalExit,
|
||||
title: record.title ?? record.config?.title ?? null,
|
||||
requiresAttention: record.requiresAttention ?? false,
|
||||
attentionReason: record.attentionReason ?? null,
|
||||
@@ -1091,10 +1086,7 @@ export class Session {
|
||||
}
|
||||
|
||||
const initPromise = (async () => {
|
||||
const record = await this.requireStoredAgentRecord(agentId);
|
||||
if (record.config?.terminal) {
|
||||
return this.ensureTerminalAgentLoaded(agentId, record);
|
||||
}
|
||||
await this.requireStoredAgentRecord(agentId);
|
||||
return this.agentLoadingService.ensureAgentLoaded({ agentId });
|
||||
})();
|
||||
|
||||
@@ -1118,39 +1110,6 @@ export class Session {
|
||||
return record;
|
||||
}
|
||||
|
||||
private async getAgentMode(agentId: string): Promise<"chat" | "terminal"> {
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
return existing.terminal ? "terminal" : "chat";
|
||||
}
|
||||
const record = await this.requireStoredAgentRecord(agentId);
|
||||
return record.config?.terminal ? "terminal" : "chat";
|
||||
}
|
||||
|
||||
private async ensureTerminalAgentLoaded(
|
||||
agentId: string,
|
||||
record: StoredAgentRecord,
|
||||
): Promise<ManagedAgent> {
|
||||
const timestamps = extractTimestamps(record);
|
||||
const snapshot = await this.agentManager.launchTerminalAgent(buildSessionConfig(record), agentId, {
|
||||
persistence: record.persistence ?? null,
|
||||
createdAt: timestamps.createdAt,
|
||||
updatedAt: timestamps.updatedAt,
|
||||
lastUserMessageAt: timestamps.lastUserMessageAt,
|
||||
labels: timestamps.labels,
|
||||
attention: {
|
||||
requiresAttention: record.requiresAttention ?? false,
|
||||
attentionReason: record.attentionReason ?? null,
|
||||
attentionTimestamp: record.attentionTimestamp ? new Date(record.attentionTimestamp) : null,
|
||||
},
|
||||
});
|
||||
this.sessionLogger.info(
|
||||
{ agentId, provider: record.provider },
|
||||
"Terminal agent loaded from stored config",
|
||||
);
|
||||
return this.agentManager.getAgent(agentId) ?? snapshot;
|
||||
}
|
||||
|
||||
private matchesAgentFilter(options: {
|
||||
agent: AgentSnapshotPayload;
|
||||
project: ProjectPlacementPayload;
|
||||
@@ -2618,9 +2577,6 @@ export class Session {
|
||||
},
|
||||
);
|
||||
await this.forwardAgentUpdate(snapshot);
|
||||
if (sessionConfig.terminal) {
|
||||
void this.emitInitialTerminalsChangedSnapshot(resolvedWorkspace.directory);
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
const agentPayload = await this.getAgentPayloadById(snapshot.id);
|
||||
@@ -2649,29 +2605,27 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
|
||||
if (!sessionConfig.terminal) {
|
||||
void this.handleSendAgentMessage(
|
||||
snapshot.id,
|
||||
trimmedPrompt,
|
||||
resolveClientMessageId(clientMessageId),
|
||||
images,
|
||||
outputSchema ? { outputSchema } : undefined,
|
||||
).catch((promptError) => {
|
||||
this.sessionLogger.error(
|
||||
{ err: promptError, agentId: snapshot.id },
|
||||
`Failed to run initial prompt for agent ${snapshot.id}`,
|
||||
);
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
|
||||
},
|
||||
});
|
||||
void this.handleSendAgentMessage(
|
||||
snapshot.id,
|
||||
trimmedPrompt,
|
||||
resolveClientMessageId(clientMessageId),
|
||||
images,
|
||||
outputSchema ? { outputSchema } : undefined,
|
||||
).catch((promptError) => {
|
||||
this.sessionLogger.error(
|
||||
{ err: promptError, agentId: snapshot.id },
|
||||
`Failed to run initial prompt for agent ${snapshot.id}`,
|
||||
);
|
||||
this.emit({
|
||||
type: "activity_log",
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: "error",
|
||||
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (worktreeBootstrap) {
|
||||
@@ -5694,13 +5648,6 @@ export class Session {
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const agentMode = await this.getAgentMode(msg.agentId);
|
||||
if (agentMode === "terminal") {
|
||||
throw new SessionRequestError(
|
||||
"unsupported_agent_kind",
|
||||
`Agent ${msg.agentId} is a terminal agent and has no timeline history`,
|
||||
);
|
||||
}
|
||||
const snapshot = await this.ensureAgentLoaded(msg.agentId);
|
||||
const agentPayload = await this.buildAgentPayload(snapshot);
|
||||
const timeline = await this.agentManager.fetchTimeline(msg.agentId, {
|
||||
@@ -5773,22 +5720,6 @@ export class Session {
|
||||
}
|
||||
|
||||
try {
|
||||
const structuredSendRejection = await this.agentManager.getStructuredSendRejection(
|
||||
resolved.agentId,
|
||||
);
|
||||
if (structuredSendRejection) {
|
||||
this.emit({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId: resolved.agentId,
|
||||
accepted: false,
|
||||
error: structuredSendRejection,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = resolved.agentId;
|
||||
await this.unarchiveAgentState(agentId);
|
||||
|
||||
@@ -7221,7 +7152,7 @@ export class Session {
|
||||
}
|
||||
|
||||
private filterStandaloneTerminals<T extends { id: string }>(terminals: T[]): T[] {
|
||||
return terminals.filter((terminal) => !this.agentManager.isTerminalBoundToAgent(terminal.id));
|
||||
return terminals;
|
||||
}
|
||||
|
||||
private toTerminalInfo(terminal: Pick<TerminalSession, "id" | "name" | "getTitle">): {
|
||||
@@ -7328,46 +7259,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async createOrResumeAgentTerminal(agentId: string): Promise<TerminalSession> {
|
||||
if (!this.terminalManager) {
|
||||
throw new Error("Terminal manager not available");
|
||||
}
|
||||
|
||||
const existingTerminal = this.agentManager.getTerminalSessionForAgent(agentId);
|
||||
if (existingTerminal) {
|
||||
return existingTerminal;
|
||||
}
|
||||
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
if (!record || record.internal) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
if (!record.config?.terminal) {
|
||||
throw new Error(`Agent ${agentId} is not a terminal agent`);
|
||||
}
|
||||
|
||||
const timestamps = extractTimestamps(record);
|
||||
const launched = await this.agentManager.launchTerminalAgent(buildSessionConfig(record), agentId, {
|
||||
persistence: record.persistence ?? null,
|
||||
createdAt: timestamps.createdAt,
|
||||
updatedAt: timestamps.updatedAt,
|
||||
lastUserMessageAt: timestamps.lastUserMessageAt,
|
||||
labels: timestamps.labels,
|
||||
attention: {
|
||||
requiresAttention: record.requiresAttention ?? false,
|
||||
attentionReason: record.attentionReason ?? null,
|
||||
attentionTimestamp: record.attentionTimestamp ? new Date(record.attentionTimestamp) : null,
|
||||
},
|
||||
});
|
||||
const terminal = launched.terminalId
|
||||
? this.terminalManager.getTerminal(launched.terminalId)
|
||||
: null;
|
||||
if (!terminal) {
|
||||
throw new Error(`Terminal not available for agent ${agentId}`);
|
||||
}
|
||||
return terminal;
|
||||
}
|
||||
|
||||
private async getAllTerminalSessions(): Promise<TerminalSession[]> {
|
||||
if (!this.terminalManager) {
|
||||
return [];
|
||||
@@ -7395,18 +7286,11 @@ export class Session {
|
||||
|
||||
try {
|
||||
if (msg.agentId) {
|
||||
const terminal = await this.createOrResumeAgentTerminal(msg.agentId);
|
||||
this.ensureTerminalExitSubscription(terminal);
|
||||
this.emit({
|
||||
type: "create_terminal_response",
|
||||
payload: {
|
||||
terminal: {
|
||||
id: terminal.id,
|
||||
name: terminal.name,
|
||||
cwd: terminal.cwd,
|
||||
...(terminal.getTitle() ? { title: terminal.getTitle() } : {}),
|
||||
},
|
||||
error: null,
|
||||
terminal: null,
|
||||
error: `Agent-backed terminals are no longer supported for agent ${msg.agentId}`,
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
|
||||
function makeAgent(input: {
|
||||
id: string;
|
||||
@@ -39,7 +38,6 @@ function makeAgent(input: {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
@@ -222,50 +220,6 @@ function seedWorkspace(options: {
|
||||
return record;
|
||||
}
|
||||
|
||||
function createStoredTerminalAgentRecord(input: {
|
||||
id: string;
|
||||
cwd: string;
|
||||
}): StoredAgentRecord {
|
||||
return {
|
||||
id: input.id,
|
||||
provider: "codex",
|
||||
cwd: input.cwd,
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
lastActivityAt: "2026-03-01T12:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "closed",
|
||||
lastModeId: null,
|
||||
config: {
|
||||
terminal: true,
|
||||
},
|
||||
runtimeInfo: {
|
||||
provider: "codex",
|
||||
sessionId: null,
|
||||
},
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: input.id,
|
||||
nativeHandle: input.id,
|
||||
},
|
||||
lastError: null,
|
||||
terminalExit: {
|
||||
command: "codex",
|
||||
message: "Terminal session ended",
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
outputLines: [],
|
||||
},
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
internal: false,
|
||||
archivedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createTempGitRepo(options?: {
|
||||
remoteUrl?: string;
|
||||
branchName?: string;
|
||||
@@ -635,89 +589,6 @@ describe("workspace aggregation", () => {
|
||||
expect(sessionLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("terminal agents reject timeline fetch without reloading as chat sessions", async () => {
|
||||
const emitted: Array<{ type: string; payload: any }> = [];
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const resumeAgentFromPersistence = vi.fn();
|
||||
const launchTerminalAgent = vi.fn();
|
||||
const hydrateTimelineFromProvider = vi.fn();
|
||||
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
onMessage: (message) => emitted.push(message as any),
|
||||
logger: logger as any,
|
||||
downloadTokenStore: {} as any,
|
||||
pushTokenStore: {} as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
resumeAgentFromPersistence,
|
||||
launchTerminalAgent,
|
||||
hydrateTimelineFromProvider,
|
||||
} as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
get: async (agentId: string) =>
|
||||
agentId === "terminal-1"
|
||||
? createStoredTerminalAgentRecord({ id: agentId, cwd: "/tmp/repo" })
|
||||
: null,
|
||||
} as any,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
}) as any;
|
||||
|
||||
await session.handleMessage({
|
||||
type: "fetch_agent_timeline_request",
|
||||
requestId: "req-terminal-timeline",
|
||||
agentId: "terminal-1",
|
||||
});
|
||||
|
||||
expect(resumeAgentFromPersistence).not.toHaveBeenCalled();
|
||||
expect(launchTerminalAgent).not.toHaveBeenCalled();
|
||||
expect(hydrateTimelineFromProvider).not.toHaveBeenCalled();
|
||||
expect(emitted).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: expect.objectContaining({
|
||||
requestId: "req-terminal-timeline",
|
||||
agentId: "terminal-1",
|
||||
error: "Agent terminal-1 is a terminal agent and has no timeline history",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("uses persisted workspace names and stable status aggregation", async () => {
|
||||
const { session, projects, workspaces } = createSessionForWorkspaceTests();
|
||||
seedProject({
|
||||
|
||||
@@ -30,7 +30,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
};
|
||||
|
||||
type Deferred<T> = {
|
||||
|
||||
@@ -95,7 +95,6 @@ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
|
||||
supportsMcpServers: z.boolean(),
|
||||
supportsReasoningStream: z.boolean(),
|
||||
supportsToolInvocations: z.boolean(),
|
||||
supportsTerminalMode: z.boolean(),
|
||||
});
|
||||
|
||||
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
|
||||
@@ -133,7 +132,6 @@ const McpServerConfigSchema = z.discriminatedUnion("type", [
|
||||
const AgentSessionConfigSchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
cwd: z.string(),
|
||||
terminal: z.boolean().optional(),
|
||||
modeId: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
@@ -458,19 +456,10 @@ const AgentRuntimeInfoSchema: z.ZodType<AgentRuntimeInfo> = z.object({
|
||||
extra: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const TerminalExitDetailsSchema = z.object({
|
||||
command: z.string(),
|
||||
message: z.string(),
|
||||
exitCode: z.number().nullable(),
|
||||
signal: z.number().nullable(),
|
||||
outputLines: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const AgentSnapshotPayloadSchema = z.object({
|
||||
id: z.string(),
|
||||
provider: AgentProviderSchema,
|
||||
cwd: z.string(),
|
||||
terminal: z.boolean().optional(),
|
||||
model: z.string().nullable(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
effectiveThinkingOptionId: z.string().nullable().optional(),
|
||||
@@ -486,7 +475,6 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
runtimeInfo: AgentRuntimeInfoSchema.optional(),
|
||||
lastUsage: AgentUsageSchema.optional(),
|
||||
lastError: z.string().optional(),
|
||||
terminalExit: TerminalExitDetailsSchema.optional(),
|
||||
title: z.string().nullable(),
|
||||
labels: z.record(z.string()).default({}),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
|
||||
@@ -359,69 +359,6 @@ describe("TerminalManager", () => {
|
||||
10000,
|
||||
);
|
||||
|
||||
it("forwards bound terminal titles through the agent bridge without changing standalone lists", async () => {
|
||||
await withShell("/bin/sh", async () => {
|
||||
const onAgentBoundTerminalTitleChange = vi.fn();
|
||||
manager = createTerminalManager({
|
||||
resolveAgentIdForTerminal: () => "agent-1",
|
||||
onAgentBoundTerminalTitleChange,
|
||||
});
|
||||
|
||||
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
|
||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||
snapshots.push(
|
||||
input.terminals.map((terminal) => ({
|
||||
id: terminal.id,
|
||||
...(terminal.title ? { title: terminal.title } : {}),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||
session.send({ type: "input", data: "printf '\\033]0;Agent Shell\\007'\r" });
|
||||
|
||||
await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000);
|
||||
|
||||
expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
title: "Agent Shell",
|
||||
});
|
||||
expect(
|
||||
snapshots.some((snapshot) =>
|
||||
snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Agent Shell"),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards initial titles for agent-bound terminals created with command args", async () => {
|
||||
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-manager-title-script-"));
|
||||
temporaryDirs.push(packageRoot);
|
||||
const scriptPath = join(packageRoot, "npm-cli.js");
|
||||
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
|
||||
|
||||
const onAgentBoundTerminalTitleChange = vi.fn();
|
||||
manager = createTerminalManager({
|
||||
resolveAgentIdForTerminal: () => "agent-1",
|
||||
onAgentBoundTerminalTitleChange,
|
||||
});
|
||||
|
||||
await manager.createTerminal({
|
||||
cwd: packageRoot,
|
||||
command: process.execPath,
|
||||
args: [scriptPath, "run", "dev"],
|
||||
});
|
||||
|
||||
await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000);
|
||||
|
||||
expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
title: "npm run dev",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits empty snapshot when last terminal is removed", async () => {
|
||||
manager = createTerminalManager();
|
||||
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
|
||||
|
||||
@@ -33,12 +33,7 @@ export interface TerminalManager {
|
||||
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
|
||||
}
|
||||
|
||||
type AgentBoundTerminalTitleHandler = (input: { agentId: string; title: string }) => Promise<void> | void;
|
||||
|
||||
export function createTerminalManager(options?: {
|
||||
resolveAgentIdForTerminal?: (terminalId: string) => string | null;
|
||||
onAgentBoundTerminalTitleChange?: AgentBoundTerminalTitleHandler;
|
||||
}): TerminalManager {
|
||||
export function createTerminalManager(): TerminalManager {
|
||||
const terminalsByCwd = new Map<string, TerminalSession[]>();
|
||||
const terminalsById = new Map<string, TerminalSession>();
|
||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||
@@ -111,24 +106,8 @@ export function createTerminalManager(options?: {
|
||||
const unsubscribeExit = session.onExit(() => {
|
||||
removeSessionById(session.id, { kill: false });
|
||||
});
|
||||
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||
const unsubscribeTitle = session.onTitleChange(() => {
|
||||
emitTerminalsChanged({ cwd: session.cwd });
|
||||
const normalizedTitle = title?.trim();
|
||||
if (!normalizedTitle) {
|
||||
return;
|
||||
}
|
||||
const agentId = options?.resolveAgentIdForTerminal?.(session.id) ?? null;
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(
|
||||
options?.onAgentBoundTerminalTitleChange?.({
|
||||
agentId,
|
||||
title: normalizedTitle,
|
||||
}),
|
||||
).catch(() => {
|
||||
// no-op
|
||||
});
|
||||
});
|
||||
terminalExitUnsubscribeById.set(session.id, unsubscribeExit);
|
||||
terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle);
|
||||
|
||||
Reference in New Issue
Block a user