diff --git a/.env.example b/.env.example index fb3c4da95..2e5972d3b 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,13 @@ MEDIA_ROOT=~/.dispatch/media # TLS (optional — both must be set to enable HTTPS) # TLS_CERT=~/.dispatch/tls/cert.pem # TLS_KEY=~/.dispatch/tls/key.pem + +# Dispatch Harness engines (agent type "dispatch"). Each is a host install the +# service resolves with its own PATH, so use absolute paths. The engine is the +# first segment of the agent's model id: claude/…, codex/…, gemini/…, opencode/…. +# DISPATCH_CLAUDE_HARNESS_BIN=~/.local/bin/claude-agent-acp +# DISPATCH_CODEX_HARNESS_BIN=~/.local/bin/codex-acp +# DISPATCH_GEMINI_BIN=~/.local/bin/gemini +# DISPATCH_OPENCODE_BIN=~/.local/bin/opencode +# The engines use the host CLIs' own logins (claude /login, codex login +# --device-auth, NO_BROWSER=true gemini, opencode auth login); no key here. diff --git a/.gitignore b/.gitignore index 0e9dd543a..4ce752e10 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ opencode.json .cursor/mcp.json apps/server/src/generated/runtime-assets/ apps/server/src/generated/runtime-assets.js +*.bun-build diff --git a/apps/server/package.json b/apps/server/package.json index 51864242d..4996eb8a6 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,6 +15,7 @@ "db:migrate": "bun run prepare:runtime-assets && bun src/db/migrate.ts" }, "dependencies": { + "@agentclientprotocol/sdk": "1.4.0", "@dispatch/shared": "workspace:*", "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^9.4.0", diff --git a/apps/server/src/agent-type-settings.ts b/apps/server/src/agent-type-settings.ts index d9f243fa8..ff430924e 100644 --- a/apps/server/src/agent-type-settings.ts +++ b/apps/server/src/agent-type-settings.ts @@ -1,8 +1,9 @@ import type { Pool } from "pg"; import { getSetting, setSetting } from "./db/settings.js"; +import { isDispatchHarnessEnabled } from "./dispatch-harness-settings.js"; import { - AGENT_TYPES, + DEFAULT_ENABLED_AGENT_TYPES, sanitizeEnabledAgentTypes, type AgentType, } from "./shared/agent-types.js"; @@ -10,6 +11,7 @@ import { export { AGENT_TYPES, CLI_AGENT_TYPES, + DEFAULT_ENABLED_AGENT_TYPES, isCliAgentType, sanitizeEnabledAgentTypes, type AgentType, @@ -21,13 +23,13 @@ const ENABLED_AGENT_TYPES_KEY = "enabled_agent_types"; export async function getEnabledAgentTypes(pool: Pool): Promise { const raw = await getSetting(pool, ENABLED_AGENT_TYPES_KEY); if (!raw) { - return [...AGENT_TYPES]; + return [...DEFAULT_ENABLED_AGENT_TYPES]; } try { return sanitizeEnabledAgentTypes(JSON.parse(raw)); } catch { - return [...AGENT_TYPES]; + return [...DEFAULT_ENABLED_AGENT_TYPES]; } } @@ -39,3 +41,24 @@ export async function setEnabledAgentTypes( await setSetting(pool, ENABLED_AGENT_TYPES_KEY, JSON.stringify(sanitized)); return sanitized; } + +/** + * What the app may create right now: the persisted enabled types plus + * `dispatch` when and only when the Dispatch Harness flag is on. + * + * Every creation and discovery gate reads this, not `getEnabledAgentTypes`: + * the create route, the reviewer-type route, `dispatch_launch_agent`, persona + * launches, the plugin routes and the assisted-update driver picker. That is + * what makes the harness's one switch reach all of them at once. + * + * No duplicate is possible: `sanitizeEnabledAgentTypes` drops `dispatch` from + * the persisted list on every branch, so the append below is the only place + * it can enter. + */ +export async function getOfferedAgentTypes(pool: Pool): Promise { + const [enabled, harnessEnabled] = await Promise.all([ + getEnabledAgentTypes(pool), + isDispatchHarnessEnabled(pool), + ]); + return harnessEnabled ? [...enabled, "dispatch"] : enabled; +} diff --git a/apps/server/src/agents/activity-monitor.ts b/apps/server/src/agents/activity-monitor.ts index e079a1af6..01a48616a 100644 --- a/apps/server/src/agents/activity-monitor.ts +++ b/apps/server/src/agents/activity-monitor.ts @@ -83,7 +83,10 @@ export function createActivityMonitor( FROM agents WHERE deleted_at IS NULL AND status = 'running' - AND tmux_session IS NOT NULL` + AND tmux_session IS NOT NULL + -- harness agents derive working/idle from their ACP stream; their + -- pane is a plain shell whose quiet would only demote them. + AND type <> 'dispatch'` ); const runningIds = new Set(); diff --git a/apps/server/src/agents/archive.ts b/apps/server/src/agents/archive.ts index 550cf40ab..240735e38 100644 --- a/apps/server/src/agents/archive.ts +++ b/apps/server/src/agents/archive.ts @@ -25,6 +25,8 @@ export type ArchiveDeps = { getAgent: (id: string) => Promise; getRequiredAgent: (id: string) => Promise; harvestAgentTokens: (agent: AgentRecord) => Promise; + /** Stop a protocol-driven harness that lives outside the tmux pane. */ + stopHarness?: (agent: AgentRecord) => Promise; setAgentStatus: ( id: string, status: AgentStatus, @@ -261,6 +263,7 @@ export async function executeArchive( "Stop hook failed during archive; continuing" ) ); + await deps.stopHarness?.(agent); if (agent.tmuxSession && (await runtime.hasSession(agent.tmuxSession))) { await runtime.stopSession(agent.tmuxSession, true); } @@ -423,6 +426,7 @@ export async function deleteAgentDirect( "Stop hook failed during delete; continuing" ) ); + await deps.stopHarness?.(agent); if (agent.tmuxSession && sessionExists) { await runtime.stopSession(agent.tmuxSession, true); } diff --git a/apps/server/src/agents/harness/agent-spec.ts b/apps/server/src/agents/harness/agent-spec.ts new file mode 100644 index 000000000..146aee402 --- /dev/null +++ b/apps/server/src/agents/harness/agent-spec.ts @@ -0,0 +1,117 @@ +import { HARNESS_ENGINE_IDS, type HarnessEngineId } from "@dispatch/shared"; + +export type EngineBins = { + claudeHarnessBin: string; + codexHarnessBin: string; + geminiBin: string; + opencodeBin: string; + /** Absolute path to the host's `claude`, for `CLAUDE_CODE_EXECUTABLE`. */ + claudeBin: string; + /** Absolute path to the host's `codex`, or null to run the adapter's bundled one. */ + codexBin: string | null; +}; + +export type FullAccess = + /** Already in `args`. */ + | { kind: "args" } + /** Already in `env`. */ + | { kind: "env" } + /** `session/set_mode` to this mode id right after the session opens. */ + | { kind: "set_mode"; modeId: string } + /** The agent asks per call; the driver's requestPermission handler allows. */ + | { kind: "permission_request" }; + +export type EngineSpec = { + id: HarnessEngineId; + bin: string; + args: string[]; + env: NodeJS.ProcessEnv; + /** + * `system_prompt`: `session/new` and `session/resume` carry + * `_meta.systemPrompt.append`. `first_prompt`: the persona is the leading + * block of a fresh session's first prompt. + */ + personaDelivery: "system_prompt" | "first_prompt"; + fullAccess: FullAccess; + /** Declare `_meta["subagent-transcript"]` at initialize and nest by parentToolUseId. */ + subagentTranscripts: boolean; + /** The model is a launch flag, so `/model` cannot switch it. */ + modelFixedAtLaunch: boolean; +}; + +const ENGINE_IDS: readonly string[] = HARNESS_ENGINE_IDS; + +export function splitModelId(model: string): { + engine: HarnessEngineId; + model: string; +} { + const slash = model.indexOf("/"); + if (slash <= 0 || slash === model.length - 1) { + throw new Error(`harness model ids are engine/model; got "${model}"`); + } + const engine = model.slice(0, slash); + if (!ENGINE_IDS.includes(engine)) { + throw new Error(`unknown engine "${engine}" in model id "${model}"`); + } + return { engine: engine as HarnessEngineId, model: model.slice(slash + 1) }; +} + +export function engineSpecFor( + engine: HarnessEngineId, + model: string, + bins: EngineBins +): EngineSpec { + switch (engine) { + case "claude": + return { + id: engine, + bin: bins.claudeHarnessBin, + args: ["--dangerously-skip-permissions"], + env: { CLAUDE_CODE_EXECUTABLE: bins.claudeBin }, + personaDelivery: "system_prompt", + fullAccess: { kind: "args" }, + subagentTranscripts: true, + modelFixedAtLaunch: false, + }; + case "codex": + return { + id: engine, + bin: bins.codexHarnessBin, + args: [], + env: { + INITIAL_AGENT_MODE: "agent-full-access", + NO_BROWSER: "1", + ...(bins.codexBin ? { CODEX_PATH: bins.codexBin } : {}), + }, + personaDelivery: "first_prompt", + fullAccess: { kind: "env" }, + subagentTranscripts: false, + modelFixedAtLaunch: false, + }; + case "gemini": + return { + id: engine, + bin: bins.geminiBin, + args: [ + "--experimental-acp", + ...(model !== "default" ? ["--model", model] : []), + ], + env: {}, + personaDelivery: "first_prompt", + fullAccess: { kind: "set_mode", modeId: "yolo" }, + subagentTranscripts: false, + modelFixedAtLaunch: true, + }; + case "opencode": + return { + id: engine, + bin: bins.opencodeBin, + args: ["acp"], + env: {}, + personaDelivery: "first_prompt", + fullAccess: { kind: "permission_request" }, + subagentTranscripts: false, + modelFixedAtLaunch: false, + }; + } +} diff --git a/apps/server/src/agents/harness/auth-status.ts b/apps/server/src/agents/harness/auth-status.ts new file mode 100644 index 000000000..b35d858a9 --- /dev/null +++ b/apps/server/src/agents/harness/auth-status.ts @@ -0,0 +1,153 @@ +import { readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { + HarnessAuthKind, + HarnessAuthReport, + HarnessAuthStatus, +} from "@dispatch/shared"; + +import { + runCommand, + type CommandRunner, +} from "../../shared/lib/run-command.js"; + +export type HarnessAuthBins = { + claude: string; + codex: string; + gemini: string; + opencode: string; +}; + +type FileReader = (file: string, encoding: "utf8") => Promise; + +function status( + engineId: HarnessAuthStatus["engineId"], + kind: HarnessAuthKind, + label: string, + detail?: string +): HarnessAuthStatus { + return { engineId, kind, label, ...(detail ? { detail } : {}) }; +} + +export function parseCodexAuth(output: string): HarnessAuthStatus { + if (/chatgpt/i.test(output)) { + return status("codex", "subscription", "ChatGPT subscription"); + } + if (/api[ _-]?key/i.test(output)) { + return status("codex", "api_key", "OpenAI API key"); + } + if (/not logged in|logged out/i.test(output)) { + return status("codex", "not_signed_in", "Not signed in"); + } + return status("codex", "configured", "Codex login configured"); +} + +export function parseClaudeAuth(output: string): HarnessAuthStatus { + try { + const auth = JSON.parse(output) as { + loggedIn?: boolean; + authMethod?: string; + subscriptionType?: string; + }; + if (auth.loggedIn === false) { + return status("claude", "not_signed_in", "Not signed in"); + } + if (/api.?key/i.test(auth.authMethod ?? "")) { + return status("claude", "api_key", "Anthropic API key"); + } + if (auth.authMethod === "claude.ai") { + const tier = auth.subscriptionType?.trim(); + return status( + "claude", + "subscription", + tier ? `Claude ${tier} subscription` : "Claude subscription" + ); + } + } catch {} + if (/not logged in|logged out/i.test(output)) { + return status("claude", "not_signed_in", "Not signed in"); + } + return status("claude", "configured", "Claude login configured"); +} + +export function parseGeminiAuth(selectedType: unknown): HarnessAuthStatus { + if (typeof selectedType !== "string" || !selectedType.trim()) { + return status("gemini", "not_signed_in", "Not signed in"); + } + if (/api.?key|gemini-api|vertex/i.test(selectedType)) { + return status("gemini", "api_key", "Google API key"); + } + if (/oauth|google/i.test(selectedType)) { + return status("gemini", "oauth", "Google account"); + } + return status("gemini", "configured", "Gemini login configured"); +} + +async function commandStatus( + engineId: "claude" | "codex" | "opencode", + command: string, + args: string[], + runner: CommandRunner +): Promise { + try { + const result = await runner(command, args, { + allowedExitCodes: [0, 1], + timeoutMs: 4_000, + }); + const output = `${result.stdout}\n${result.stderr}`.trim(); + if (engineId === "codex") return parseCodexAuth(output); + if (engineId === "claude") return parseClaudeAuth(output); + if (/0 credentials|no credentials|not logged in/i.test(output)) { + return status("opencode", "not_signed_in", "Not signed in"); + } + return status("opencode", "configured", "Provider login configured"); + } catch { + return status(engineId, "unavailable", "Login status unavailable"); + } +} + +export async function loadHarnessAuthReport( + bins: HarnessAuthBins, + options: { + runner?: CommandRunner; + read?: FileReader; + homeDir?: string; + now?: Date; + } = {} +): Promise { + const runner = options.runner ?? runCommand; + const read = options.read ?? readFile; + const homeDir = options.homeDir ?? os.homedir(); + const [claude, codex, opencode, gemini] = await Promise.all([ + commandStatus("claude", bins.claude, ["auth", "status"], runner), + commandStatus("codex", bins.codex, ["login", "status"], runner), + commandStatus("opencode", bins.opencode, ["auth", "list"], runner), + read(path.join(homeDir, ".gemini", "settings.json"), "utf8") + .then((raw) => { + const parsed = JSON.parse(raw) as { + security?: { auth?: { selectedType?: unknown } }; + }; + return parseGeminiAuth(parsed.security?.auth?.selectedType); + }) + .catch(() => status("gemini", "unavailable", "Login status unavailable")), + ]); + return { + checkedAt: (options.now ?? new Date()).toISOString(), + engines: [claude, codex, gemini, opencode], + }; +} + +export function createHarnessAuthReporter( + bins: HarnessAuthBins, + ttlMs = 60_000 +): () => Promise { + let cached: { expiresAt: number; report: HarnessAuthReport } | null = null; + return async () => { + if (cached && cached.expiresAt > Date.now()) return cached.report; + const report = await loadHarnessAuthReport(bins); + cached = { expiresAt: Date.now() + ttlMs, report }; + return report; + }; +} diff --git a/apps/server/src/agents/harness/driver.ts b/apps/server/src/agents/harness/driver.ts new file mode 100644 index 000000000..0dd1fef91 --- /dev/null +++ b/apps/server/src/agents/harness/driver.ts @@ -0,0 +1,572 @@ +import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"; +import { access, constants as fsConstants } from "node:fs/promises"; +import path from "node:path"; +import { Readable, Writable } from "node:stream"; +import * as acp from "@agentclientprotocol/sdk"; +import type { EngineSpec } from "./agent-spec.js"; + +export type DriverUpdate = acp.SessionUpdate; +export type DriverUsage = acp.Usage; + +export type DriverLaunch = { + agentId: string; + cwd: string; + engine: EngineSpec; + /** The persona, for an engine whose spec says `system_prompt`; null otherwise. */ + systemPromptAppend: string | null; + mcp: { url: string; token: string }; + /** Resume this ACP session when set; falls back to a new one if the engine lost it. */ + sessionId: string | null; + env: NodeJS.ProcessEnv; +}; + +export type DriverEvent = + | { type: "update"; agentId: string; update: DriverUpdate } + | { type: "turn"; agentId: string; state: "started"; text: string } + | { + type: "turn"; + agentId: string; + state: "settled"; + stopReason?: acp.StopReason; + /** Cumulative session usage reported with the prompt response. */ + usage?: DriverUsage; + error?: string; + } + | { + type: "exit"; + agentId: string; + code: number | null; + signal: string | null; + stderrTail: string; + /** True when Dispatch asked the child to stop; false for a crash. */ + expected: boolean; + }; + +export type DriverListener = (event: DriverEvent) => void; + +export type DriverLogger = { + info: (obj: Record, msg: string) => void; + warn: (obj: Record, msg: string) => void; + error: (obj: Record, msg: string) => void; + debug: (obj: Record, msg: string) => void; +}; + +export type ChildProcessLike = Pick< + ChildProcess, + "stdin" | "stdout" | "stderr" | "on" | "kill" | "killed" +>; + +export type SpawnFn = ( + bin: string, + args: string[], + opts: { cwd: string; env: NodeJS.ProcessEnv } +) => ChildProcessLike; + +type ExitInfo = { code: number | null; signal: string | null; error?: Error }; + +type Live = { + child: ChildProcessLike; + conn: acp.ClientSideConnection; + sessionId: string; + startedAt: string; + stderrTail: string[]; + exited: Promise; + stopping: boolean; + config: { options: acp.SessionConfigOption[] }; + commands: { list: acp.AvailableCommand[] }; +}; + +const STDERR_TAIL_LINES = 20; +/** + * One rung of the teardown ladder in {@link HarnessDriver.stop}. Exported so + * the supervisor's own shutdown bound is derived from it rather than guessed + * alongside it. + */ +export const TEARDOWN_STEP_MS = 1_500; +const HANDSHAKE_TIMEOUT_MS = 30_000; + +/** + * The ACP SDK reports an agent-side exception as JSON-RPC "Internal error" + * and keeps the real message in `data.details` (the harness itself does the + * same for a failed turn), so surface that detail instead of the bare code. + */ +function describeRpcError(err: unknown): string { + if (!(err instanceof Error)) return String(err); + const data = (err as { data?: unknown }).data; + let detail: string | null = null; + if (typeof data === "string") detail = data; + else if (data && typeof data === "object") { + const details = (data as { details?: unknown }).details; + if (typeof details === "string") detail = details; + else if (Object.keys(data).length > 0) detail = JSON.stringify(data); + } + return detail && !err.message.includes(detail) + ? `${err.message}: ${detail}` + : err.message; +} + +/** + * Find the engine's executable before spawning, so a missing binary is a + * clear message on the agent instead of a spawn error. The server resolves + * it with its own PATH (launchd/systemd), not the user's login shell, so + * the message points at the setting to fix. + */ +export async function resolveExecutable( + bin: string, + env: NodeJS.ProcessEnv +): Promise { + const executable = async (candidate: string) => { + try { + await access(candidate, fsConstants.X_OK); + return true; + } catch { + return false; + } + }; + if (bin.includes("/")) { + const absolute = path.resolve(bin); + if (await executable(absolute)) return absolute; + throw new Error(`${bin} is not executable at ${absolute}`); + } + const searchPath = env.PATH ?? process.env.PATH ?? ""; + for (const dir of searchPath.split(path.delimiter)) { + if (!dir) continue; + const candidate = path.join(dir, bin); + if (await executable(candidate)) return candidate; + } + throw new Error( + `${bin} was not found on the server's PATH; set the engine's DISPATCH_*_BIN to an absolute path` + ); +} + +function defaultSpawn( + bin: string, + args: string[], + opts: { cwd: string; env: NodeJS.ProcessEnv } +): ChildProcessLike { + return nodeSpawn(bin, args, { ...opts, stdio: ["pipe", "pipe", "pipe"] }); +} + +function describeExit(exit: ExitInfo): string { + if (exit.error) { + const code = (exit.error as NodeJS.ErrnoException).code; + return code === "ENOENT" + ? `the harness could not be spawned (${exit.error.message})` + : exit.error.message; + } + return exit.code === null + ? `the harness exited on signal ${exit.signal}` + : `the harness exited with code ${exit.code}`; +} + +export class HarnessDriver { + private readonly live = new Map(); + private readonly listeners = new Set(); + private readonly spawnFn: SpawnFn; + private readonly resolveBinary: ( + bin: string, + env: NodeJS.ProcessEnv + ) => Promise; + + constructor( + private readonly opts: { + spawn?: SpawnFn; + /** Injectable for tests that spawn a fake; defaults to a PATH lookup. */ + resolveBinary?: (bin: string, env: NodeJS.ProcessEnv) => Promise; + logger: DriverLogger; + } + ) { + this.spawnFn = opts.spawn ?? defaultSpawn; + this.resolveBinary = opts.resolveBinary ?? resolveExecutable; + } + + onEvent(listener: DriverListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + isRunning(agentId: string): boolean { + return this.live.has(agentId); + } + + liveAgentIds(): string[] { + return [...this.live.keys()]; + } + + async start( + launch: DriverLaunch + ): Promise<{ sessionId: string; resumed: boolean }> { + if (this.live.has(launch.agentId)) { + throw new Error(`the harness is already running for ${launch.agentId}`); + } + const { engine } = launch; + const env: NodeJS.ProcessEnv = { ...launch.env, ...engine.env }; + const bin = await this.resolveBinary(engine.bin, env); + const child = this.spawnFn(bin, engine.args, { cwd: launch.cwd, env }); + // Both listeners go on before any await: a spawn failure (ENOENT, EACCES, + // missing cwd) is an `error` event with no `exit`, and an unhandled one + // would take the whole server down. + const stderrTail: string[] = []; + let settledExit: ExitInfo | null = null; + const exited = new Promise((resolve) => { + child.on("exit", (code, signal) => + resolve({ code, signal: signal ?? null }) + ); + child.on("error", (error: Error) => + resolve({ code: null, signal: null, error }) + ); + }); + void exited.then((exit) => { + settledExit = exit; + }); + child.stderr?.on("data", (chunk: Buffer) => { + for (const line of chunk.toString("utf8").split("\n")) { + if (!line.trim()) continue; + stderrTail.push(line); + if (stderrTail.length > STDERR_TAIL_LINES) stderrTail.shift(); + } + }); + + const config = { options: [] as acp.SessionConfigOption[] }; + const commands: { list: acp.AvailableCommand[] } = { list: [] }; + const client: acp.Client = { + sessionUpdate: async (params) => { + if (params.update.sessionUpdate === "config_option_update") { + config.options = params.update.configOptions ?? []; + } else if ( + params.update.sessionUpdate === "available_commands_update" + ) { + commands.list = params.update.availableCommands ?? []; + } + this.emit({ + type: "update", + agentId: launch.agentId, + update: params.update, + }); + }, + // An engine that asks per call (OpenCode) gets the allow option; the + // others never ask under the full access their spec grants. With no + // allow option, end the call cleanly rather than pick at random. + requestPermission: async (params) => { + const allow = params.options.find( + (o) => o.kind === "allow_once" || o.kind === "allow_always" + ); + if (!allow) { + this.opts.logger.warn( + { + agentId: launch.agentId, + options: params.options.map((o) => o.kind), + }, + "permission request had no allow option; cancelling" + ); + return { outcome: { outcome: "cancelled" } }; + } + return { outcome: { outcome: "selected", optionId: allow.optionId } }; + }, + }; + if (!child.stdin || !child.stdout) { + child.kill("SIGKILL"); + throw new Error("harness start failed: child has no stdio pipes"); + } + const stream = acp.ndJsonStream( + Writable.toWeb(child.stdin), + Readable.toWeb(child.stdout) + ); + const conn = new acp.ClientSideConnection(() => client, stream); + + const sessionMeta = launch.systemPromptAppend + ? { _meta: { systemPrompt: { append: launch.systemPromptAppend } } } + : {}; + const handshake = (async () => { + await conn.initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + ...(engine.subagentTranscripts + ? { _meta: { "subagent-transcript": true } } + : {}), + }, + }); + const mcpServers: acp.McpServer[] = [ + { + type: "http", + name: "dispatch", + url: launch.mcp.url, + headers: [ + { name: "Authorization", value: `Bearer ${launch.mcp.token}` }, + ], + }, + ]; + let session: { sessionId: string; resumed: boolean } | null = null; + if (launch.sessionId) { + try { + // session/resume, not session/load: load replays the history as + // updates and the recorder would write every turn again. + const resumed = await conn.resumeSession({ + sessionId: launch.sessionId, + cwd: launch.cwd, + mcpServers, + ...sessionMeta, + }); + config.options = resumed.configOptions ?? config.options; + session = { sessionId: launch.sessionId, resumed: true }; + } catch (err) { + // The engine no longer has the session (home cleared, store + // pruned, or an earlier start died after the id was recorded). A + // fresh session beats an agent that can never start again. + this.opts.logger.warn( + { err, agentId: launch.agentId, sessionId: launch.sessionId }, + "the engine could not resume the stored session; starting a new one" + ); + } + } + if (!session) { + const res = await conn.newSession({ + cwd: launch.cwd, + mcpServers, + ...sessionMeta, + }); + config.options = res.configOptions ?? config.options; + session = { sessionId: res.sessionId, resumed: false }; + } + if (engine.fullAccess.kind === "set_mode") { + try { + await conn.setSessionMode({ + sessionId: session.sessionId, + modeId: engine.fullAccess.modeId, + }); + } catch (err) { + this.opts.logger.warn( + { err, agentId: launch.agentId, modeId: engine.fullAccess.modeId }, + "the engine refused the full-access mode; continuing with its default" + ); + } + } + return session; + })(); + + type Outcome = + | { ok: true; session: { sessionId: string; resumed: boolean } } + | { ok: false; reason: string }; + const outcome = await Promise.race([ + handshake.then( + (session) => ({ ok: true, session }), + (err) => ({ ok: false, reason: describeRpcError(err) }) + ), + exited.then((exit) => ({ + ok: false, + reason: `${describeExit(exit)} during startup`, + })), + new Promise((resolve) => + setTimeout( + () => + resolve({ + ok: false, + reason: `the engine did not complete the ACP handshake within ${HANDSHAKE_TIMEOUT_MS / 1000}s`, + }), + HANDSHAKE_TIMEOUT_MS + ).unref?.() + ), + ]); + if (!outcome.ok) { + handshake.catch(() => {}); + child.kill("SIGKILL"); + // A spawn failure aborts the handshake too, and that rejection can win + // the race; the child's own exit reason is the useful one. + const reason = settledExit + ? `${describeExit(settledExit)} during startup` + : outcome.reason; + const tail = stderrTail.length ? `\n${stderrTail.join("\n")}` : ""; + throw new Error(`harness start failed: ${reason}${tail}`); + } + + const entry: Live = { + child, + conn, + sessionId: outcome.session.sessionId, + startedAt: new Date().toISOString(), + stderrTail, + exited, + stopping: false, + config, + commands, + }; + this.live.set(launch.agentId, entry); + void exited.then((exit) => { + if (this.live.get(launch.agentId) === entry) { + this.live.delete(launch.agentId); + } + this.emit({ + type: "exit", + agentId: launch.agentId, + code: exit.code, + signal: exit.signal, + stderrTail: stderrTail.join("\n"), + expected: entry.stopping, + }); + }); + this.opts.logger.info( + { + agentId: launch.agentId, + sessionId: entry.sessionId, + resumed: outcome.session.resumed, + }, + "harness session ready" + ); + return outcome.session; + } + + getConfigOptions(agentId: string): acp.SessionConfigOption[] | null { + return this.live.get(agentId)?.config.options ?? null; + } + + getSessionStartedAt(agentId: string): string | null { + return this.live.get(agentId)?.startedAt ?? null; + } + + getCommands(agentId: string): acp.AvailableCommand[] | null { + return this.live.get(agentId)?.commands.list ?? null; + } + + async setConfigOption( + agentId: string, + configId: string, + value: string + ): Promise { + const entry = this.require(agentId); + try { + const res = await entry.conn.setSessionConfigOption({ + sessionId: entry.sessionId, + configId, + value, + }); + entry.config.options = res.configOptions ?? entry.config.options; + return entry.config.options; + } catch (err) { + throw new Error(describeRpcError(err), { cause: err }); + } + } + + /** + * Runs one turn; resolves when the agent settles it. + * + * `onAccepted` runs once the child is live and the request has been + * handed to it. Nothing before that point reached the engine, so a + * caller that records a prompt as delivered has to wait for this rather + * than for the turn being queued. + */ + async prompt( + agentId: string, + text: string, + onAccepted?: () => void + ): Promise { + const entry = this.require(agentId); + this.emit({ type: "turn", agentId, state: "started", text }); + // A child that exits mid-turn never answers the request; the pending + // call would hang and hold the agent's turn slot for ever. + let exited = false; + const gone = entry.exited.then(() => { + exited = true; + throw new Error("the harness exited before the turn settled"); + }); + try { + const dispatched = entry.conn.prompt({ + sessionId: entry.sessionId, + prompt: [{ type: "text", text }], + }); + onAccepted?.(); + const res = await Promise.race([dispatched, gone]); + this.emit({ + type: "turn", + agentId, + state: "settled", + stopReason: res.stopReason, + ...(res.usage ? { usage: res.usage } : {}), + }); + } catch (err) { + const message = describeRpcError(err); + // The exit event already settled the turn row; a second settle would + // only add a duplicate status line. + if (!exited) { + this.emit({ type: "turn", agentId, state: "settled", error: message }); + } + throw new Error(message, { cause: err }); + } + } + + async cancel(agentId: string): Promise { + const entry = this.require(agentId); + await entry.conn.cancel({ sessionId: entry.sessionId }); + } + + /** Close the session, then walk stdin EOF, SIGTERM, SIGKILL until exit. */ + async stop(agentId: string): Promise { + const entry = this.live.get(agentId); + if (!entry) return; + entry.stopping = true; + try { + await Promise.race([ + entry.conn.closeSession({ sessionId: entry.sessionId }), + new Promise((resolve) => setTimeout(resolve, TEARDOWN_STEP_MS)), + ]); + } catch (err) { + this.opts.logger.debug( + { err, agentId }, + "harness session close failed; continuing teardown" + ); + } + const exitedWithin = (ms: number) => + Promise.race([ + entry.exited.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), ms)), + ]); + entry.child.stdin?.end(); + if (!(await exitedWithin(TEARDOWN_STEP_MS))) entry.child.kill("SIGTERM"); + if (!(await exitedWithin(TEARDOWN_STEP_MS))) entry.child.kill("SIGKILL"); + await entry.exited; + this.live.delete(agentId); + } + + /** + * SIGKILL every child still live and forget it, without waiting for an + * exit. The last step of a bounded shutdown: an engine child holds + * full-access permissions and a live MCP token, the installed service unit + * uses KillMode=process, and the caller is about to call process.exit(), + * which would drop a kill the ladder had only just started. Synchronous on + * purpose, so no await can be cut short between the signal and the exit. + */ + killAll(): string[] { + const killed: string[] = []; + for (const [agentId, entry] of this.live) { + try { + entry.child.kill("SIGKILL"); + killed.push(agentId); + } catch (err) { + this.opts.logger.warn( + { err, agentId }, + "harness child could not be killed at shutdown" + ); + } + } + this.live.clear(); + return killed; + } + + private emit(event: DriverEvent): void { + for (const listener of this.listeners) { + try { + listener(event); + } catch (err) { + this.opts.logger.warn({ err }, "harness driver listener threw"); + } + } + } + + private require(agentId: string): Live { + const entry = this.live.get(agentId); + if (!entry) throw new Error(`the harness is not running for ${agentId}`); + return entry; + } +} diff --git a/apps/server/src/agents/harness/paths.ts b/apps/server/src/agents/harness/paths.ts new file mode 100644 index 000000000..8762867ec --- /dev/null +++ b/apps/server/src/agents/harness/paths.ts @@ -0,0 +1,103 @@ +import { readdir, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { HarnessPath } from "@dispatch/shared"; + +import { shouldSkipAutomaticMacPathProbe } from "../../shared/mac-path-privacy.js"; + +/** + * Completions for the Harness composer's "@" path picker: the entries of + * the directory the typed prefix names, filtered by its last segment. The + * reply keeps the spelling the user typed (relative, "~/…", or absolute) + * so the picked path reads the same way in the prompt. + */ + +const MAX_QUERY_LENGTH = 1024; +const MAX_ENTRIES = 50; + +/** Whether a directory is the agent's working tree or something inside it. */ +function isInsideTree(dir: string, cwd: string): boolean { + const rel = path.relative(cwd, dir); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + +/** Where a typed prefix points: the directory to list, and the segment to match. */ +export function resolvePathQuery( + query: string, + input: { cwd: string; home?: string } +): { dir: string; typedDir: string; segment: string } | null { + if (query.length > MAX_QUERY_LENGTH || query.includes("\0")) return null; + const idx = query.lastIndexOf("/"); + const typedDir = idx >= 0 ? query.slice(0, idx + 1) : ""; + const segment = idx >= 0 ? query.slice(idx + 1) : query; + const home = input.home ?? os.homedir(); + let dir: string; + if (typedDir === "") dir = input.cwd; + else if (typedDir.startsWith("/")) dir = typedDir; + else if (typedDir === "~/" || typedDir.startsWith("~/")) + dir = path.join(home, typedDir.slice(2)); + else dir = path.join(input.cwd, typedDir); + return { dir, typedDir, segment }; +} + +/** + * Entries matching the typed prefix: directories first, then files, by + * name. Each kind is capped on its own, so a directory whose files sort + * ahead of its subdirectories still lists those subdirectories. + */ +export async function listHarnessPaths( + query: string, + input: { cwd: string; home?: string; platform?: NodeJS.Platform } +): Promise { + const resolved = resolvePathQuery(query, input); + if (!resolved) return []; + const { dir, typedDir, segment } = resolved; + // "~" alone completes to the home directory before anything is listed. + if (typedDir === "" && segment === "~") return [{ path: "~", kind: "dir" }]; + // On macOS a service that reads ~/Desktop, ~/Documents, ~/Downloads or + // iCloud Drive raises a TCC prompt no daemon can answer, so the read hangs + // or is denied silently. /api/v1/system/path-completions refuses these + // before readdir and so does this. + const home = input.home ?? os.homedir(); + if (shouldSkipAutomaticMacPathProbe(dir, home, input.platform)) return []; + // Outside the agent's working tree only directories list, which is the + // posture of the completion route this parallels. Inside it, naming files + // is the whole point of the picker. + const dirsOnly = !isInsideTree(dir, input.cwd); + const showHidden = segment.startsWith("."); + const needle = segment.toLowerCase(); + let entries: import("node:fs").Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + const matched = entries + .filter((entry) => { + if (!showHidden && entry.name.startsWith(".")) return false; + return entry.name.toLowerCase().startsWith(needle); + }) + .sort((a, b) => a.name.localeCompare(b.name)); + const dirs: HarnessPath[] = []; + const files: HarnessPath[] = []; + for (const entry of matched) { + if (dirs.length >= MAX_ENTRIES && files.length >= MAX_ENTRIES) break; + let isDir = entry.isDirectory(); + if (entry.isSymbolicLink()) { + try { + isDir = (await stat(path.join(dir, entry.name))).isDirectory(); + } catch { + continue; + } + } + if (!isDir && dirsOnly) continue; + const bucket = isDir ? dirs : files; + if (bucket.length >= MAX_ENTRIES) continue; + bucket.push({ + path: typedDir + entry.name, + kind: isDir ? "dir" : "file", + }); + } + return [...dirs, ...files].slice(0, MAX_ENTRIES); +} diff --git a/apps/server/src/agents/harness/persona.ts b/apps/server/src/agents/harness/persona.ts new file mode 100644 index 000000000..4be953f07 --- /dev/null +++ b/apps/server/src/agents/harness/persona.ts @@ -0,0 +1,58 @@ +import type { AgentRecord } from "@dispatch/shared"; + +import { + buildLaunchGuidance, + extractAppendedSystemPrompt, +} from "../tmux/command-builder.js"; + +/** The Harness composer's slash menu sends "/ …" as plain text. */ +const HARNESS_SLASH_RULE = + 'A user message that begins with "/" names a slash command or skill: run it, treating the rest of the message as its input. If none has that name, say so briefly.'; + +/** How a harness agent's output reaches the user; replaces the pane-era chat rule. */ +export const HARNESS_CHAT_RULE = + "The user is reading the Chat tab. Your replies appear there as you write them, so answer in plain text and do not repeat a reply through dispatch_chat_post. Use dispatch_chat_post only for a question that needs a choice (kind: question with options)."; + +/** + * The system-prompt persona for a harness agent. CLI agents get the same + * pieces as separate `--append-system-prompt` flags; the harness takes one + * persona string (in `_meta.systemPrompt.append` for Claude, as the first + * prompt's leading block for the other engines), so this joins them. + */ +export function buildHarnessPersona(input: { + agent: Pick< + AgentRecord, + "id" | "type" | "agentArgs" | "persona" | "autoReview" + >; + personalityPrompt: string | null; + trimmedGuidance: boolean; + /** Accepted for parity with the CLI inputs; the harness always assumes Chat. */ + chatSurface?: boolean; + suggestSessionRename: boolean; + /** A job run: the guidance names the job tools (job_complete, …). */ + jobRunId?: string | null; +}): string { + const { agent } = input; + // The pane-driven chat rule sends replies through dispatch_chat_post; a + // harness agent's text already streams into Chat, so that rule would make + // it answer twice. It gets its own rule below instead. + const guidance = buildLaunchGuidance(agent.id, { + agentType: agent.type, + ...(input.jobRunId ? { jobRunId: input.jobRunId } : {}), + suggestSessionRename: input.suggestSessionRename, + autoReview: !agent.persona && agent.autoReview, + trimmedGuidance: input.trimmedGuidance, + chatSurface: false, + }); + // A persona launch stores its brief as `--append-system-prompt ` + // in agentArgs. + const { appendedSystemPrompt } = extractAppendedSystemPrompt( + agent.agentArgs ?? [] + ); + const sections = [guidance.trim(), HARNESS_CHAT_RULE, HARNESS_SLASH_RULE]; + if (appendedSystemPrompt?.trim()) sections.push(appendedSystemPrompt.trim()); + else if (input.personalityPrompt?.trim()) { + sections.push(input.personalityPrompt.trim()); + } + return sections.join("\n\n"); +} diff --git a/apps/server/src/agents/harness/prompt-source.ts b/apps/server/src/agents/harness/prompt-source.ts new file mode 100644 index 000000000..2aebbf5e0 --- /dev/null +++ b/apps/server/src/agents/harness/prompt-source.ts @@ -0,0 +1,55 @@ +/** + * What a prompt sent to the harness was, for the prompt a turn entry + * renders. The wire text is an envelope Dispatch built; the feed wants the + * human-facing source behind it, not the envelope. + */ +export type PromptSource = + | { source: "chat"; chatMessageId: string } + | { source: "agent"; senderId: string; senderName: string; text: string } + | { source: "system"; text: string }; + +// The id has to be the strict UUID shape, not 36 characters of the same +// alphabet: it is read back through a `::uuid[]` cast, and a value Postgres +// rejects there turns every later read of that agent's turns into a 500. The +// header is matched on every prompt that reaches the queue, and review +// injection prompts embed feedback bodies verbatim, so the text is not +// always Dispatch's own. +const CHAT_HEADER = + /^--- DISPATCH CHAT \(id: ([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\) ---/m; +const MESSAGE_BLOCK = + /^--- DISPATCH MESSAGE ---\n([\s\S]*?)\n--- END MESSAGE ---/m; +const SYSTEM_MAX = 500; + +export function parsePromptSource(text: string): PromptSource { + const chat = CHAT_HEADER.exec(text); + if (chat) return { source: "chat", chatMessageId: chat[1] }; + const message = MESSAGE_BLOCK.exec(text); + if (message) { + try { + const body = JSON.parse(message[1]) as { + from?: unknown; + senderId?: unknown; + message?: unknown; + }; + if (typeof body.message === "string") { + return { + source: "agent", + senderId: typeof body.senderId === "string" ? body.senderId : "", + senderName: typeof body.from === "string" ? body.from : "agent", + text: body.message, + }; + } + } catch { + // Not JSON after all; treat the whole thing as a system prompt. + } + } + return { source: "system", text: text.slice(0, SYSTEM_MAX) }; +} + +/** A prompt waiting its turn in the supervisor's queue, as routes read it. */ +export type QueuedPrompt = { + /** The chat message id for a chat prompt; otherwise a queue-local id. */ + id: string; + source: PromptSource; + createdAt: string; +}; diff --git a/apps/server/src/agents/harness/provider-usage.ts b/apps/server/src/agents/harness/provider-usage.ts new file mode 100644 index 000000000..3550ea431 --- /dev/null +++ b/apps/server/src/agents/harness/provider-usage.ts @@ -0,0 +1,320 @@ +import { readFile, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import type { + HarnessPlanSpend, + HarnessPlanWindow, + HarnessProviderPlan, + HarnessProviderUsageReport, +} from "@dispatch/shared"; + +import { discoverCodexRolloutFiles } from "../codex-sessions.js"; + +type ProviderUsageOptions = { + now?: Date; + homeDir?: string; + read?: (file: string) => Promise; + codexFiles?: () => Promise; + modifiedAt?: (file: string) => Promise; +}; + +type JsonObject = Record; + +function object(value: unknown): JsonObject | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonObject) + : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value ? value : null; +} + +function percent(value: unknown): number | null { + const number = finiteNumber(value); + return number === null ? null : Math.max(0, Math.min(100, number)); +} + +function planName(value: unknown): string | null { + const raw = text(value); + if (!raw) return null; + return raw + .split(/[_-]+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function durationLabel(minutes: number | null, fallback: string): string { + if (minutes === null || minutes <= 0) return fallback; + if (minutes % 10_080 === 0) { + const weeks = minutes / 10_080; + return weeks === 1 ? "Weekly" : `${weeks}-week`; + } + if (minutes % 1_440 === 0) return `${minutes / 1_440}-day`; + if (minutes % 60 === 0) return `${minutes / 60}-hour`; + return `${minutes}-minute`; +} + +function window( + id: string, + label: string, + usedPercent: unknown, + resetsAt: unknown +): HarnessPlanWindow | null { + const used = percent(usedPercent); + if (used === null) return null; + return { + id, + label, + usedPercent: used, + resetsAt: + typeof resetsAt === "number" + ? new Date(resetsAt * 1_000).toISOString() + : text(resetsAt), + }; +} + +function parseMoney(value: unknown): number | null { + const money = object(value); + const amount = finiteNumber(money?.amount_minor); + const exponent = finiteNumber(money?.exponent); + if (amount === null || exponent === null) return null; + return amount / 10 ** exponent; +} + +export function parseClaudeProviderUsage(raw: string): HarnessProviderPlan { + try { + const root = object(JSON.parse(raw)); + const cache = object(root?.cachedUsageUtilization); + const utilization = object(cache?.utilization); + const limits = Array.isArray(utilization?.limits) ? utilization.limits : []; + const windows: HarnessPlanWindow[] = []; + + for (const value of limits) { + const limit = object(value); + const kind = text(limit?.kind); + const scope = object(limit?.scope); + const model = object(scope?.model); + const modelName = text(model?.display_name); + const label = + kind === "session" + ? "5-hour" + : kind === "weekly_all" + ? "Weekly" + : kind === "weekly_scoped" && modelName + ? `${modelName} weekly` + : planName(kind); + const item = + kind && label + ? window( + kind + (modelName ? `:${modelName}` : ""), + label, + limit?.percent, + limit?.resets_at + ) + : null; + if (item) windows.push(item); + } + + if (windows.length === 0) { + const fiveHour = object(utilization?.five_hour); + const weekly = object(utilization?.seven_day); + const first = window( + "session", + "5-hour", + fiveHour?.utilization, + fiveHour?.resets_at + ); + const second = window( + "weekly_all", + "Weekly", + weekly?.utilization, + weekly?.resets_at + ); + if (first) windows.push(first); + if (second) windows.push(second); + } + + const spendValue = object(utilization?.spend); + const used = parseMoney(spendValue?.used); + const limit = parseMoney(spendValue?.limit); + const currency = text(object(spendValue?.used)?.currency); + const spend: HarnessPlanSpend | undefined = + used !== null && limit !== null && currency + ? { used, limit, currency } + : undefined; + + const fetchedAtMs = finiteNumber(cache?.fetchedAtMs); + return { + engineId: "claude", + plan: null, + observedAt: + fetchedAtMs === null ? null : new Date(fetchedAtMs).toISOString(), + windows, + ...(spend ? { spend } : {}), + ...(windows.length === 0 && !spend + ? { unavailableReason: "No Claude plan utilization is cached yet." } + : {}), + }; + } catch { + return { + engineId: "claude", + plan: null, + observedAt: null, + windows: [], + unavailableReason: "No Claude plan utilization is cached yet.", + }; + } +} + +type CodexRateLimits = { + timestamp: string | null; + value: JsonObject; +}; + +export function parseCodexProviderUsage(raw: string): HarnessProviderPlan { + let latest: CodexRateLimits | null = null; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + const entry = object(JSON.parse(line)); + if (entry?.type !== "event_msg") continue; + const payload = object(entry.payload); + const limits = object(payload?.rate_limits); + if (payload?.type === "token_count" && limits) { + latest = { timestamp: text(entry.timestamp), value: limits }; + } + } catch { + continue; + } + } + + if (!latest) { + return { + engineId: "codex", + plan: null, + observedAt: null, + windows: [], + unavailableReason: "No Codex plan utilization has been reported yet.", + }; + } + + const windows: HarnessPlanWindow[] = []; + for (const [id, fallback] of [ + ["primary", "Primary limit"], + ["secondary", "Secondary limit"], + ] as const) { + const value = object(latest.value[id]); + const minutes = finiteNumber(value?.window_minutes); + const item = window( + id, + durationLabel(minutes, fallback), + value?.used_percent, + value?.resets_at + ); + if (item) windows.push(item); + } + + return { + engineId: "codex", + plan: planName(latest.value.plan_type), + observedAt: latest.timestamp, + windows, + ...(windows.length === 0 + ? { + unavailableReason: "No Codex plan utilization has been reported yet.", + } + : {}), + }; +} + +function unavailable( + engineId: "gemini" | "opencode", + reason: string +): HarnessProviderPlan { + return { + engineId, + plan: null, + observedAt: null, + windows: [], + unavailableReason: reason, + }; +} + +export async function loadHarnessProviderUsage( + options: ProviderUsageOptions = {} +): Promise { + const read = options.read ?? ((file: string) => readFile(file, "utf8")); + const modifiedAt = + options.modifiedAt ?? (async (file: string) => (await stat(file)).mtimeMs); + const files = await (options.codexFiles ?? discoverCodexRolloutFiles)(); + const candidates = await Promise.all( + files.map(async (file) => { + try { + return { file, modifiedAt: await modifiedAt(file) }; + } catch { + return null; + } + }) + ); + candidates.sort((a, b) => (b?.modifiedAt ?? 0) - (a?.modifiedAt ?? 0)); + + let codex = parseCodexProviderUsage(""); + for (const candidate of candidates) { + if (!candidate) continue; + try { + codex = parseCodexProviderUsage(await read(candidate.file)); + if (codex.windows.length > 0) break; + } catch { + continue; + } + } + + let claude: HarnessProviderPlan; + try { + claude = parseClaudeProviderUsage( + await read( + path.join(options.homeDir ?? os.homedir(), ".claude", ".claude.json") + ) + ); + } catch { + claude = parseClaudeProviderUsage(""); + } + + const now = options.now ?? new Date(); + return { + checkedAt: now.toISOString(), + providers: [ + claude, + codex, + unavailable( + "gemini", + "Gemini CLI does not expose plan limits to Dispatch." + ), + unavailable( + "opencode", + "OpenCode plan limits depend on its configured provider." + ), + ], + }; +} + +export function createHarnessProviderUsageReporter( + options: Omit = {}, + cacheMs = 60_000 +): () => Promise { + let cached: { expiresAt: number; report: HarnessProviderUsageReport } | null = + null; + return async () => { + const now = Date.now(); + if (cached && cached.expiresAt > now) return cached.report; + const report = await loadHarnessProviderUsage(options); + cached = { expiresAt: now + cacheMs, report }; + return report; + }; +} diff --git a/apps/server/src/agents/harness/stream-recorder.ts b/apps/server/src/agents/harness/stream-recorder.ts new file mode 100644 index 000000000..6a7709d3a --- /dev/null +++ b/apps/server/src/agents/harness/stream-recorder.ts @@ -0,0 +1,596 @@ +import path from "node:path"; + +import type { DriverEvent, DriverUpdate } from "./driver.js"; +import { parsePromptSource } from "./prompt-source.js"; +import type { + PlanPayload, + StreamEventRow, + StreamStore, + ToolPayload, + TurnPayload, +} from "./stream-store.js"; + +type TextKind = "assistant" | "thought"; + +type OpenText = { + row: StreamEventRow; + text: string; + truncated: boolean; + written: string; + flushTimer: NodeJS.Timeout | null; + writing: Promise; +}; + +/** Model output is not trusted input: bound what one row can hold. */ +export const TEXT_MAX_BYTES = 64 * 1024; +export const TERMINAL_OUTPUT_MAX_BYTES = 32 * 1024; +export const AUTONOMOUS_IDLE_MS = 20_000; +export const INTERRUPTED_BY_RESTART = "interrupted by restart"; +export const FLUSH_INTERVAL_MS = 100; + +/** + * The engine's ACP server sends tool calls without a `kind`; the title is + * the tool name, which is enough to pick the icon and color the Chat row gets. + */ +export function inferToolKind( + kind: string | null | undefined, + title: string +): string { + // The engine sends "other" explicitly, which says nothing; treat it as missing. + if (kind && kind !== "other") return kind; + const name = title.toLowerCase(); + if (/^mcp__/.test(name)) return "other"; + if (/bash|shell|pwsh|exec|terminal|command/.test(name)) return "execute"; + if (/edit|write|str_replace|patch|create_file/.test(name)) return "edit"; + if (/^read|read_file|cat\b|view/.test(name)) return "read"; + if (/grep|glob|search|find|list|^ls\b/.test(name)) return "search"; + if (/fetch|web|http|browse/.test(name)) return "fetch"; + if (/think|plan|todo/.test(name)) return "think"; + return "other"; +} + +function textOf(content: { type: string; text?: string } | undefined): string { + return content && content.type === "text" && typeof content.text === "string" + ? content.text + : ""; +} + +export function boundOutput( + text: string, + maxBytes: number +): { text: string; truncated: boolean } { + const bytes = Buffer.from(text, "utf8"); + if (bytes.byteLength <= maxBytes) return { text, truncated: false }; + const half = Math.floor(maxBytes / 2); + const head = bytes.subarray(0, half).toString("utf8"); + const tail = bytes.subarray(-half).toString("utf8"); + return { text: `${head}\n… [truncated] …\n${tail}`, truncated: true }; +} + +const INPUT_MAX_BYTES = 8 * 1024; + +/** + * Keep a tool call's raw input as sent, unless serializing it is large: + * then a bounded string preview stands in, marked so the view can say so. + */ +export function boundInput(input: unknown): unknown { + if (input === undefined || input === null) return undefined; + let json: string; + try { + json = JSON.stringify(input); + } catch { + return undefined; + } + if (json === undefined) return undefined; + if (Buffer.byteLength(json, "utf8") <= INPUT_MAX_BYTES) return input; + return { + truncated: true, + preview: boundOutput(json, INPUT_MAX_BYTES).text, + }; +} + +function parentToolCallIdOf(meta: unknown): string | null { + if (typeof meta !== "object" || meta === null) return null; + const claude = (meta as { claudeCode?: unknown }).claudeCode; + if (typeof claude !== "object" || claude === null) return null; + const parent = (claude as { parentToolUseId?: unknown }).parentToolUseId; + return typeof parent === "string" && parent ? parent : null; +} + +function projectToolContent(content: readonly unknown[] | null | undefined): { + diff: ToolPayload["diff"]; + terminalOutput: string | null; + truncated: boolean; +} { + let diff: ToolPayload["diff"] = null; + let terminalOutput: string | null = null; + for (const item of content ?? []) { + const c = item as { + type: string; + path?: string; + oldText?: string | null; + newText?: string; + content?: { type: string; text?: string }; + }; + if (c.type === "diff" && c.path && typeof c.newText === "string") { + diff = { path: c.path, oldText: c.oldText ?? null, newText: c.newText }; + } else if (c.type === "content" && c.content?.type === "text") { + terminalOutput = (terminalOutput ?? "") + (c.content.text ?? ""); + } + } + let truncated = false; + if (terminalOutput !== null) { + const bounded = boundOutput(terminalOutput, TERMINAL_OUTPUT_MAX_BYTES); + terminalOutput = bounded.text; + truncated = bounded.truncated; + } + if (diff) { + // Both halves, not just the new one: engines that write whole files + // (Gemini CLI's write_file, OpenCode's write tool) send the entire + // previous file as oldText, and the row is read back on every chat feed + // page, every turns read and every coalesced refetch. + const newBounded = boundOutput(diff.newText, TEXT_MAX_BYTES); + const oldBounded = + diff.oldText === null ? null : boundOutput(diff.oldText, TEXT_MAX_BYTES); + if (newBounded.truncated || oldBounded?.truncated) { + diff = { + ...diff, + newText: newBounded.text, + oldText: oldBounded ? oldBounded.text : null, + }; + truncated = true; + } + } + return { diff, terminalOutput, truncated }; +} + +/** + * Folds driver events into `agent_stream_events` rows. Assistant and + * thought chunks accumulate into one open row each until something else + * interrupts them (a tool call, a settled turn, a process exit); the row is + * rewritten at most every {@link FLUSH_INTERVAL_MS} and on close. Tool calls + * are keyed by toolCallId and rewritten as they settle. One instance serves + * every agent; open-row state is per agent, and callers serialize events + * per agent (see HarnessSupervisor). + */ +export class StreamRecorder { + private readonly open = new Map< + string, + Partial> + >(); + private readonly cwd = new Map(); + private readonly openTurn = new Map(); + /** + * A turn the engine opened on its own (a goal round) has no prompt response + * to end it; it settles once the stream has been quiet for a while, or when + * something else starts. + */ + private readonly autonomousIdle = new Map(); + + constructor( + private readonly store: StreamStore, + private readonly deps: { + autonomousIdleMs?: number; + onAutonomousSettled?: (agentId: string) => void; + } = {} + ) {} + + setCwd(agentId: string, cwd: string): void { + this.cwd.set(agentId, cwd); + } + + async handle(event: DriverEvent): Promise { + switch (event.type) { + case "update": + return this.handleUpdate(event.agentId, event.update); + case "turn": { + if (event.state === "started") { + await this.settleAutonomous(event.agentId); + const row = await this.store.append(event.agentId, "turn", { + state: "started", + prompt: parsePromptSource(event.text), + } satisfies TurnPayload); + this.openTurn.set(event.agentId, row); + return; + } + await this.closeText(event.agentId); + const open = this.openTurn.get(event.agentId); + if (open) { + const prev = open.payload as TurnPayload; + await this.store.updatePayload(open.id, { + ...prev, + state: "settled", + ...(event.stopReason ? { stopReason: event.stopReason } : {}), + ...(event.error ? { error: event.error } : {}), + endedAt: new Date().toISOString(), + } satisfies TurnPayload); + this.openTurn.delete(event.agentId); + } + if (event.error) { + await this.store.append(event.agentId, "status", { + message: event.error, + }); + } + return; + } + case "exit": { + await this.closeText(event.agentId); + this.cwd.delete(event.agentId); + // The child is gone, so the turn it was running can never settle + // through the prompt path; settle it here or the view spins forever. + const open = this.openTurn.get(event.agentId); + if (open) { + const prev = open.payload as TurnPayload; + await this.store.updatePayload(open.id, { + ...prev, + state: "settled", + ...(event.expected + ? { stopReason: "cancelled" } + : { error: "the harness exited before the turn settled" }), + endedAt: new Date().toISOString(), + } satisfies TurnPayload); + this.openTurn.delete(event.agentId); + } + if (event.expected || event.code === 0) return; + const how = + event.code === null ? `signal ${event.signal}` : `code ${event.code}`; + const detail = event.stderrTail ? `: ${event.stderrTail}` : ""; + await this.store.append(event.agentId, "status", { + message: `the harness exited with ${how}${detail}`, + }); + return; + } + } + } + + /** + * Before a session starts: settle rows a previous process left open (a + * turn interrupted by a server restart has no in-memory state here). + */ + async reconcile(agentId: string): Promise { + const timer = this.autonomousIdle.get(agentId); + if (timer) clearTimeout(timer); + this.autonomousIdle.delete(agentId); + this.openTurn.delete(agentId); + return this.store.settleInterrupted(agentId, INTERRUPTED_BY_RESTART); + } + + /** + * When the agent's newest turn ended because Dispatch restarted, the + * time it was cut; null when it ended any other way. + */ + async lastTurnInterruptedByRestartAt(agentId: string): Promise { + const last = await this.store.lastTurnSettlement(agentId); + if (!last || last.error !== INTERRUPTED_BY_RESTART) return null; + const at = last.endedAt ? Date.parse(last.endedAt) : NaN; + return Number.isFinite(at) ? new Date(at) : new Date(0); + } + + /** Write any buffered text for the agent now (tests and shutdown). */ + async flush(agentId: string): Promise { + const state = this.open.get(agentId); + if (!state) return; + for (const kind of ["assistant", "thought"] as const) { + const current = state[kind]; + if (current) await this.write(kind, current, true); + } + } + + private projectLocations( + agentId: string, + locations: + | readonly { path: string; line?: number | null }[] + | null + | undefined + ): ToolPayload["locations"] { + const cwd = this.cwd.get(agentId); + return (locations ?? []).map((l) => { + const relative = + cwd && (l.path === cwd || l.path.startsWith(`${cwd}${path.sep}`)) + ? path.relative(cwd, l.path) || "." + : l.path; + return l.line != null + ? { path: relative, line: l.line } + : { path: relative }; + }); + } + + /** Prompt text for a turn the engine opened by itself. */ + static readonly GOAL_ROUND_PROMPT = [ + "--- DISPATCH: GOAL ROUND ---", + "The agent continued on its own: a round of its standing goal.", + "--- END DISPATCH: GOAL ROUND ---", + ].join("\n"); + + private async openAutonomousIfNeeded( + agentId: string, + update: DriverUpdate + ): Promise { + if (this.openTurn.has(agentId)) { + this.touchAutonomous(agentId); + return; + } + // Only content opens a turn; a config change is not the agent working. + if ( + update.sessionUpdate !== "agent_message_chunk" && + update.sessionUpdate !== "agent_thought_chunk" && + update.sessionUpdate !== "tool_call" + ) { + return; + } + const row = await this.store.append(agentId, "turn", { + state: "started", + prompt: parsePromptSource(StreamRecorder.GOAL_ROUND_PROMPT), + autonomous: true, + } satisfies TurnPayload); + this.openTurn.set(agentId, row); + this.touchAutonomous(agentId); + } + + private touchAutonomous(agentId: string): void { + const open = this.openTurn.get(agentId); + if (!open || !(open.payload as TurnPayload).autonomous) return; + const prior = this.autonomousIdle.get(agentId); + if (prior) clearTimeout(prior); + const timer = setTimeout(() => { + this.autonomousIdle.delete(agentId); + void this.settleAutonomous(agentId).catch(() => {}); + }, this.deps.autonomousIdleMs ?? AUTONOMOUS_IDLE_MS); + timer.unref?.(); + this.autonomousIdle.set(agentId, timer); + } + + /** Close a turn the engine opened by itself; a no-op for a prompted turn. */ + async settleAutonomous(agentId: string): Promise { + const open = this.openTurn.get(agentId); + if (!open || !(open.payload as TurnPayload).autonomous) return; + const timer = this.autonomousIdle.get(agentId); + if (timer) clearTimeout(timer); + this.autonomousIdle.delete(agentId); + await this.closeText(agentId); + const prev = open.payload as TurnPayload; + await this.store.updatePayload(open.id, { + ...prev, + state: "settled", + stopReason: "end_turn", + endedAt: new Date().toISOString(), + } satisfies TurnPayload); + this.openTurn.delete(agentId); + this.deps.onAutonomousSettled?.(agentId); + } + + private async handleUpdate( + agentId: string, + update: DriverUpdate + ): Promise { + await this.openAutonomousIfNeeded(agentId, update); + switch (update.sessionUpdate) { + case "agent_message_chunk": + return this.appendText(agentId, "assistant", textOf(update.content)); + case "agent_thought_chunk": + return this.appendText(agentId, "thought", textOf(update.content)); + case "tool_call": { + await this.closeText(agentId); + const { diff, terminalOutput, truncated } = projectToolContent( + update.content + ); + const input = boundInput(update.rawInput); + const parentToolCallId = parentToolCallIdOf(update._meta); + const payload: ToolPayload = { + toolKind: inferToolKind(update.kind, update.title), + title: update.title, + status: update.status ?? "pending", + locations: this.projectLocations(agentId, update.locations), + diff, + terminalOutput, + ...(truncated ? { truncated: true } : {}), + ...(input !== undefined ? { input } : {}), + ...(parentToolCallId ? { parentToolCallId } : {}), + }; + await this.store.upsertByKey( + agentId, + "tool_call", + update.toolCallId, + payload + ); + return; + } + case "tool_call_update": { + // An update for a call we never saw start still gets a row, so a + // late-joining feed shows the settled call. + const existing = + (await this.store.getByKey( + agentId, + "tool_call", + update.toolCallId + )) ?? + (await this.store.append( + agentId, + "tool_call", + {}, + update.toolCallId + )); + const prev = existing.payload as Partial; + const projected = update.content + ? projectToolContent(update.content) + : null; + const truncated = + (projected?.truncated ?? false) || prev.truncated === true; + const title = update.title ?? prev.title ?? ""; + const next: ToolPayload = { + toolKind: inferToolKind(update.kind ?? prev.toolKind, title), + title, + status: update.status ?? prev.status ?? "pending", + locations: update.locations + ? this.projectLocations(agentId, update.locations) + : (prev.locations ?? []), + diff: projected?.diff ?? prev.diff ?? null, + terminalOutput: + projected?.terminalOutput ?? prev.terminalOutput ?? null, + ...(truncated ? { truncated: true } : {}), + ...(update.rawInput !== undefined + ? { input: boundInput(update.rawInput) } + : prev.input !== undefined + ? { input: prev.input } + : {}), + ...(prev.parentToolCallId + ? { parentToolCallId: prev.parentToolCallId } + : {}), + }; + await this.store.updatePayload(existing.id, next); + return; + } + case "plan": + return this.writePlan(agentId, update.entries); + case "plan_update": + // Only an item list is a task list; a file or markdown plan is prose. + if (update.plan.type !== "items") return; + return this.writePlan(agentId, update.plan.entries); + case "plan_removed": + return this.writePlan(agentId, []); + case "usage_update": + return this.writeUsage(agentId, update); + default: + return; + } + } + + /** One plan row per turn, keyed by the turn row, rewritten as the list changes. */ + private async writePlan( + agentId: string, + entries: readonly { + content: string; + status: string; + priority: string; + }[] + ): Promise { + const open = this.openTurn.get(agentId); + const key = open ? `plan:${open.id}` : "plan:pre"; + const payload: PlanPayload = { + entries: entries.map((e) => ({ + content: e.content, + status: e.status, + priority: e.priority, + })), + }; + await this.store.upsertByKey(agentId, "plan", key, payload); + } + + /** The live turn carries the engine's newest usage; nothing else stores it. */ + private async writeUsage( + agentId: string, + update: { + used: number; + size: number; + cost?: { amount: number; currency: string } | null; + } + ): Promise { + const open = this.openTurn.get(agentId); + if (!open) return; + const prev = open.payload as TurnPayload; + const next: TurnPayload = { + ...prev, + usage: { + used: update.used, + size: update.size, + ...(update.cost + ? { + cost: { + amount: update.cost.amount, + currency: update.cost.currency, + }, + } + : {}), + }, + }; + open.payload = next as Record; + await this.store.updatePayload(open.id, next); + } + + private payloadFor(kind: TextKind, current: OpenText, streaming: boolean) { + const truncated = current.truncated ? { truncated: true } : {}; + return kind === "assistant" + ? { text: current.text, streaming, ...truncated } + : { text: current.text, ...truncated }; + } + + private async write( + kind: TextKind, + current: OpenText, + streaming: boolean + ): Promise { + if (current.flushTimer) { + clearTimeout(current.flushTimer); + current.flushTimer = null; + } + if (current.written === current.text && streaming) return; + current.written = current.text; + const payload = this.payloadFor(kind, current, streaming); + current.writing = current.writing + .catch(() => {}) + .then(() => this.store.updatePayload(current.row.id, payload)); + await current.writing; + } + + private async appendText( + agentId: string, + kind: TextKind, + delta: string + ): Promise { + if (!delta) return; + const state = this.open.get(agentId) ?? {}; + const other: TextKind = kind === "assistant" ? "thought" : "assistant"; + if (state[other]) await this.closeText(agentId, other); + let current = state[kind]; + if (!current) { + const row = await this.store.append( + agentId, + kind, + kind === "assistant" + ? { text: delta, streaming: true } + : { text: delta } + ); + current = { + row, + text: delta, + truncated: false, + written: delta, + flushTimer: null, + writing: Promise.resolve(), + }; + state[kind] = current; + this.open.set(agentId, state); + return; + } + if (current.truncated) return; + current.text += delta; + if (Buffer.byteLength(current.text, "utf8") > TEXT_MAX_BYTES) { + const bounded = boundOutput(current.text, TEXT_MAX_BYTES); + current.text = bounded.text; + current.truncated = true; + await this.write(kind, current, true); + return; + } + if (!current.flushTimer) { + const pending = current; + current.flushTimer = setTimeout(() => { + pending.flushTimer = null; + void this.write(kind, pending, true).catch(() => {}); + }, FLUSH_INTERVAL_MS); + current.flushTimer.unref?.(); + } + } + + private async closeText(agentId: string, only?: TextKind): Promise { + const state = this.open.get(agentId); + if (!state) return; + for (const kind of ["assistant", "thought"] as const) { + if (only && kind !== only) continue; + const current = state[kind]; + if (!current) continue; + await this.write(kind, current, false); + delete state[kind]; + } + if (!state.assistant && !state.thought) this.open.delete(agentId); + } +} diff --git a/apps/server/src/agents/harness/stream-store.ts b/apps/server/src/agents/harness/stream-store.ts new file mode 100644 index 000000000..b416e1143 --- /dev/null +++ b/apps/server/src/agents/harness/stream-store.ts @@ -0,0 +1,240 @@ +import type { Queryable } from "../../chat/store.js"; +import type { PromptSource } from "./prompt-source.js"; + +export type StreamEventKind = + | "assistant" + | "thought" + | "tool_call" + | "status" + | "turn" + | "plan"; + +export type AssistantPayload = { + text: string; + streaming: boolean; + /** Set when the text hit the per-row size bound. */ + truncated?: boolean; +}; +export type ThoughtPayload = { text: string; truncated?: boolean }; +export type ToolPayload = { + /** Agent Client Protocol tool kind (read, edit, execute, ...) or "other". */ + toolKind: string; + title: string; + status: "pending" | "in_progress" | "completed" | "failed"; + locations: { path: string; line?: number }[]; + diff: { path: string; oldText: string | null; newText: string } | null; + terminalOutput: string | null; + /** Set when terminal output or the diff hit the per-row size bound. */ + truncated?: boolean; + /** Raw tool input from the stream (`rawInput`), bounded; see boundInput. */ + input?: unknown; + /** A nested call: the toolCallId of the step it runs under (a subagent's parent). */ + parentToolCallId?: string; +}; +export type StatusPayload = { message: string }; +export type PlanPayload = { + entries: { content: string; status: string; priority: string }[]; +}; +export type TurnPayload = { + state: "started" | "settled"; + prompt: PromptSource; + /** + * The engine started this turn itself (a goal round), so no prompt from + * Dispatch opened it and no prompt response closes it. + */ + autonomous?: boolean; + stopReason?: string; + error?: string; + endedAt?: string; + /** The engine's last usage_update in this turn: context used and, when reported, cost so far. */ + usage?: { + used: number; + size: number; + cost?: { amount: number; currency: string }; + }; +}; +export type StreamPayloadByKind = { + assistant: AssistantPayload; + thought: ThoughtPayload; + tool_call: ToolPayload; + status: StatusPayload; + turn: TurnPayload; + plan: PlanPayload; +}; + +export type StreamEventRow = { + id: number; + agentId: string; + seq: number; + kind: StreamEventKind; + key: string | null; + payload: Record; + createdAt: Date; + updatedAt: Date; +}; + +type Row = { + id: string | number; + agent_id: string; + seq: number; + kind: StreamEventKind; + key: string | null; + payload: Record; + created_at: Date; + updated_at: Date; +}; + +function toRow(r: Row): StreamEventRow { + return { + id: Number(r.id), + agentId: r.agent_id, + seq: r.seq, + kind: r.kind, + key: r.key, + payload: r.payload, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +const INSERT_SQL = ` + INSERT INTO agent_stream_events (agent_id, seq, kind, key, payload) + SELECT $1, COALESCE(MAX(seq), 0) + 1, $2, $3, $4::jsonb + FROM agent_stream_events + WHERE agent_id = $1 + RETURNING *`; + +/** + * Rows in `agent_stream_events`: the durable projection of a stream-driven + * harness (an engine over ACP) that the Chat feed reads. Append-only except for + * tool calls and plans, which are rewritten in place under their key. + */ +export class StreamStore { + constructor(private readonly db: Queryable) {} + + async append( + agentId: string, + kind: StreamEventKind, + payload: Record, + key: string | null = null + ): Promise { + const result = await this.db.query(INSERT_SQL, [ + agentId, + kind, + key, + JSON.stringify(payload), + ]); + return toRow(result.rows[0]); + } + + async getByKey( + agentId: string, + kind: StreamEventKind, + key: string + ): Promise { + const result = await this.db.query( + `SELECT * FROM agent_stream_events + WHERE agent_id = $1 AND kind = $2 AND key = $3`, + [agentId, kind, key] + ); + return result.rows[0] ? toRow(result.rows[0]) : null; + } + + async upsertByKey( + agentId: string, + kind: StreamEventKind, + key: string, + payload: Record + ): Promise { + const existing = await this.db.query( + `SELECT * FROM agent_stream_events + WHERE agent_id = $1 AND kind = $2 AND key = $3`, + [agentId, kind, key] + ); + const found = existing.rows[0]; + if (found) { + const updated = await this.db.query( + `UPDATE agent_stream_events + SET payload = $2::jsonb, updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [found.id, JSON.stringify(payload)] + ); + return toRow(updated.rows[0]); + } + return this.append(agentId, kind, payload, key); + } + + async updatePayload( + id: number, + payload: Record + ): Promise { + await this.db.query( + `UPDATE agent_stream_events + SET payload = $2::jsonb, updated_at = NOW() + WHERE id = $1`, + [id, JSON.stringify(payload)] + ); + } + + /** + * Settle whatever a dead child left open: a turn still `started` gets + * `settled` with the given error, and an assistant row still streaming + * stops. Run before a session (re)starts, so a turn cut off by a crash, + * a Stop, or a server restart never spins in the view forever. + */ + async settleInterrupted(agentId: string, error: string): Promise { + const turns = await this.db.query( + `UPDATE agent_stream_events + SET payload = payload || $2::jsonb, updated_at = NOW() + WHERE agent_id = $1 AND kind = 'turn' + AND payload->>'state' = 'started'`, + [ + agentId, + JSON.stringify({ + state: "settled", + error, + endedAt: new Date().toISOString(), + }), + ] + ); + await this.db.query( + `UPDATE agent_stream_events + SET payload = payload || '{"streaming":false}'::jsonb, updated_at = NOW() + WHERE agent_id = $1 AND kind = 'assistant' + AND payload->>'streaming' = 'true'`, + [agentId] + ); + return turns.rowCount ?? 0; + } + + /** How the agent's newest turn ended: its error, if any, and when. */ + async lastTurnSettlement( + agentId: string + ): Promise<{ error: string | null; endedAt: string | null } | null> { + const result = await this.db.query<{ + error: string | null; + ended_at: string | null; + }>( + `SELECT payload->>'error' AS error, payload->>'endedAt' AS ended_at + FROM agent_stream_events + WHERE agent_id = $1 AND kind = 'turn' + ORDER BY seq DESC LIMIT 1`, + [agentId] + ); + const row = result.rows[0]; + return row ? { error: row.error, endedAt: row.ended_at } : null; + } + + /** Newest first. */ + async list(agentId: string, limit: number): Promise { + const result = await this.db.query( + `SELECT * FROM agent_stream_events + WHERE agent_id = $1 + ORDER BY seq DESC + LIMIT $2`, + [agentId, limit] + ); + return result.rows.map(toRow); + } +} diff --git a/apps/server/src/agents/harness/supervisor.ts b/apps/server/src/agents/harness/supervisor.ts new file mode 100644 index 000000000..954754994 --- /dev/null +++ b/apps/server/src/agents/harness/supervisor.ts @@ -0,0 +1,1012 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import type { Pool } from "pg"; +import { + DEFAULT_HARNESS_MODEL, + HARNESS_ENGINES, + type AgentLatestEventType, + type AgentRecord, + type HarnessCommand, + type HarnessConfigOption, + type HarnessEngineId, +} from "@dispatch/shared"; + +import { createAgentMcpToken, createJobMcpToken } from "../../auth.js"; +import type { AppConfig } from "../../config.js"; +import { resolveMediaDir } from "../../shared/media.js"; +import { dispatchMcpUrl } from "../tmux/mcp-url.js"; +import { engineSpecFor, splitModelId, type EngineBins } from "./agent-spec.js"; +import { + HarnessDriver, + resolveExecutable, + TEARDOWN_STEP_MS, + type DriverEvent, + type DriverLogger, +} from "./driver.js"; +import { parsePromptSource, type QueuedPrompt } from "./prompt-source.js"; +import { FLUSH_INTERVAL_MS, StreamRecorder } from "./stream-recorder.js"; +import { StreamStore } from "./stream-store.js"; +import { UsageRecorder } from "./usage-recorder.js"; + +export type SupervisorDeps = { + pool: Pool; + config: Pick< + AppConfig, + | "claudeHarnessBin" + | "codexHarnessBin" + | "geminiBin" + | "opencodeBin" + | "claudeBin" + | "codexBin" + | "dispatchBinDir" + | "port" + | "tls" + | "authToken" + | "mediaRoot" + >; + logger: DriverLogger; + driver?: HarnessDriver; + resolveBinary?: (bin: string, env: NodeJS.ProcessEnv) => Promise; + getAgent: (id: string) => Promise; + setCliSessionId: (id: string, sessionId: string) => Promise; + setLatestEvent: ( + id: string, + input: { type: AgentLatestEventType; message: string } + ) => Promise; + /** + * ChatService.publishHarnessChanged: the queue is re-read after each + * stream write, and the turn itself is published separately as a feed + * row; `config` marks a session start, settle, or option switch, when + * the session config is worth re-reading too. + */ + publishHarness: (agentId: string, config?: boolean) => void; + personaPromptFor: ( + agent: AgentRecord, + jobRunId: string | null + ) => Promise; + /** + * The job run this agent is executing, if any: the harness then attaches + * the job MCP route (job_complete, job_failed, …) with the job token, + * exactly as the pane launch does. + */ + activeJobRunIdFor?: (agentId: string) => Promise; + /** + * The agent's launch prompt, already wrapped as a chat envelope, or null. + * The harness takes no launch argument, so the supervisor sends it as the + * first turn of a fresh session. + */ + launchPromptFor: (agentId: string) => Promise; + listRunningAgentIds: () => Promise; + markStartFailed: (id: string, message: string) => Promise; + setAgentModel?: (id: string, model: string | null) => Promise; + /** + * The child exited without Dispatch asking it to: the agent must not stay + * "running" over a dead harness. Falls back to a blocked event. + */ + markExited?: (id: string, message: string) => Promise; +}; + +/** + * What the harness child must not inherit from the server process. + * Everything else passes through, the same as the tmux login shell a CLI + * agent gets, so git over SSH, gh, proxies, and locale behave the same in + * both. + * + * The three engine API keys are on the list because each engine + * authenticates through the host CLI's own login, and a key left in the + * service environment would quietly authenticate the engine as someone else + * and bill that account instead. `GEMINI_API_KEY` is deliberately not on the + * list: it is one of Gemini CLI's supported logins, and the runbook says so. + */ +const ENV_DENY_EXACT = new Set([ + "OPENAI_API_KEY", + "CODEX_API_KEY", + "ANTHROPIC_API_KEY", + "DATABASE_URL", + "TEST_DATABASE_URL", + "PGPASSWORD", + "PGUSER", + "PGHOST", + "PGPORT", + "PGDATABASE", + "MEDIA_ROOT", + "TLS_CERT", + "TLS_KEY", + "TLS_CA", +]); +const ENV_DENY_PREFIX = "DISPATCH_"; + +export function buildChildEnv(input: { + agentId: string; + mediaDir: string; + config: Pick; + base?: NodeJS.ProcessEnv; + engine?: HarnessEngineId; +}): NodeJS.ProcessEnv { + const base = input.base ?? process.env; + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(base)) { + if (value === undefined) continue; + if (ENV_DENY_EXACT.has(key) || key.startsWith(ENV_DENY_PREFIX)) continue; + env[key] = value; + } + // The same contract the pane launch exports (command-builder.ts), so + // plugin skills and hooks the agent's shell tools run see one shape. + env.DISPATCH_AGENT_ID = input.agentId; + env.DISPATCH_MEDIA_DIR = input.mediaDir; + env.DISPATCH_PORT = String(input.config.port); + env.DISPATCH_SCHEME = input.config.tls ? "https" : "http"; + // Under TLS the MCP URL is loopback https; the child needs the CA the pane + // launch also exports, or every Dispatch tool call fails verification. + if (input.config.tls && base.TLS_CA && !env.NODE_EXTRA_CA_CERTS) { + env.NODE_EXTRA_CA_CERTS = base.TLS_CA; + } + // The pane launch prepends the same two entries (command-builder.ts). The + // service units pin PATH to + // /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin, which + // holds neither Dispatch's own bin/ nor the ~/.local/bin the runbook's + // install recipe uses, so without this an engine a pane agent launches + // fine is "not found on the server's PATH" here, and so is every tool the + // engine's shell runs. `resolveBinary` reads this same PATH. + const localBin = base.HOME ? path.join(base.HOME, ".local/bin") : null; + const entries = [input.config.dispatchBinDir, localBin] + .concat(env.PATH ? env.PATH.split(path.delimiter) : []) + .filter((entry): entry is string => Boolean(entry)); + env.PATH = Array.from(new Set(entries)).join(path.delimiter); + // Pin the Bash tool's cwd to the project root after every command, as the + // pane launch does, so it does not drift back to the original repo root + // over a long conversation. Claude Code's own variable; the other engines + // have no equivalent. + if (input.engine === "claude") { + env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR = "1"; + } + return env; +} + +export function modelOptionOf( + options: readonly HarnessConfigOption[] +): HarnessConfigOption | undefined { + return options.find((o) => o.id === "model" || o.category === "model"); +} + +function isModelOption( + options: readonly HarnessConfigOption[], + configId: string +): boolean { + return modelOptionOf(options)?.id === configId; +} + +/** + * How much of a Claude turn's reply is kept to test the logged-out answer + * against. The phrase has to open the reply, so a few hundred characters is + * already more than the check can use. + */ +const LOGIN_REPLY_MAX_CHARS = 300; + +/** + * Claude Code's logged-out answer, anchored to the start of the turn's + * reply. Anchored rather than matched anywhere in it because the phrase is + * ordinary prose: this repo's own runbook prints that command, so an agent + * asked about the runbook could otherwise write the phrase and have its + * session stopped mid-work. + */ +const LOGIN_REPLY_RE = /^\s*please run \/login\b/i; + +/** + * True when `err` is the ACP SDK's `RequestError.authRequired` (JSON-RPC + * code -32000) or otherwise says the engine needs a login, so a session + * start or a boot restore can end with a message the starting screen shows + * next to the engine's login command instead of a generic failure. + */ +export function loginFailureMessage( + engine: HarnessEngineId, + err: unknown +): string | null { + if (typeof err !== "object" || err === null) return null; + const code = (err as { code?: unknown }).code; + const message = (err as { message?: unknown }).message; + const isLoginFailure = + code === -32000 || + (typeof message === "string" && + /authentication required|not logged in|please run \/login/i.test( + message + )); + if (!isLoginFailure) return null; + const label = HARNESS_ENGINES.find((e) => e.id === engine)?.label ?? engine; + return `${label} is not logged in on the server.`; +} + +const MESSAGE_MAX = 200; +/** + * The ceiling on the stop race in {@link HarnessSupervisor.stopAll}. Derived + * from the driver's ladder (close, stdin EOF, SIGTERM, SIGKILL) plus a second + * of slack, so a well-behaved child always gets the whole ladder rather than + * being cut off a few hundred milliseconds short of its own SIGKILL. + */ +const STOP_ALL_TIMEOUT_MS = 4 * TEARDOWN_STEP_MS + 1_000; +const RECONCILE_TIMEOUT_MS = 2_000; + +/** + * Sent as the first turn after a restart to an agent whose previous turn + * the restart cut short. The session log carries everything the model did + * up to the cut, so it can pick the task up rather than start over. + */ +export const RESTART_PROMPT = [ + "--- DISPATCH: RESTART ---", + 'Dispatch restarted while your previous turn was running, so that turn ended early (it is marked "interrupted by restart"). Pick the task back up from where you left off: check the current state of any files you were changing before redoing work, then continue.', + "--- END DISPATCH: RESTART ---", +].join("\n"); + +type Pending = QueuedPrompt & { + text: string; + started: Promise; + markStarted: () => void; + failStarted: (err: Error) => void; + settled: Promise; + markSettled: () => void; +}; + +/** + * Glue between the agent lifecycle and the ACP driver: picks the engine + * from the agent's model id, starts it when an agent's setup completes, + * delivers the persona the way the engine takes it, turns prompts into + * turns with working/idle status around them, folds the stream into the + * store and the usage table, and stops the child when the agent stops. + */ +export class HarnessSupervisor { + private readonly driver: HarnessDriver; + private readonly store: StreamStore; + private readonly streams: StreamRecorder; + private readonly usage: UsageRecorder; + private readonly resolveBinary: ( + bin: string, + env: NodeJS.ProcessEnv + ) => Promise; + private readonly context = new Map< + string, + { sessionId: string; engine: HarnessEngineId; model: string } + >(); + /** + * Persona text an engine takes as the leading block of its first prompt + * (see EngineSpec.personaDelivery); set at a fresh start, or at a resume + * of a session that never ran a turn; consumed by the first turn. + */ + private readonly pendingPersona = new Map(); + /** + * One writer per agent. Driver events arrive faster than their DB writes + * settle; handled concurrently, two appends compute the same seq and one + * dies on the unique index, and chunk accumulation sees stale open-row + * state. Chaining each agent's events keeps order and the invariant. + */ + private readonly queues = new Map>(); + /** + * One turn at a time per agent. ACP allows one active prompt per session; + * a prompt that arrives mid-turn waits in `pending` and runs as the next + * turn once `running` clears. The list is explicit so the view can show + * it and the user can reorder or drop what has not started. + */ + private readonly pending = new Map(); + private readonly running = new Map(); + /** + * Claude Code has no `auth_required` error; it answers a turn with a plain + * "Please run /login" reply instead. What the current Claude turn has + * said, capped, and whether it called a tool: a turn that used a tool is + * working, not refusing to start, so the answer is only read as logged out + * when it opens the reply of a turn that did nothing else. Kept per turn + * and read when it settles: {@link onEvent}. + */ + private readonly turnReply = new Map< + string, + { text: string; toolCall: boolean } + >(); + /** + * Per-agent trailing timers that coalesce the publishes streamed updates + * drive. One ACP notification arrives per token chunk, and every publish + * is an SSE frame to every connected client that invalidates both the chat + * feed and the whole turn list. The window matches the recorder's own + * flush interval, so a client re-reads no more often than the rows change. + * A turn boundary or an exit publishes at once and takes the pending + * timer with it: {@link onEvent}. + */ + private readonly publishTimers = new Map(); + /** + * Set by {@link stopAll} before it snapshots what is running, so the + * snapshot cannot grow behind it: {@link pump} starts nothing more once + * shutdown has begun. + */ + private shuttingDown = false; + + constructor(private readonly deps: SupervisorDeps) { + this.resolveBinary = deps.resolveBinary ?? resolveExecutable; + this.driver = + deps.driver ?? + new HarnessDriver({ + logger: deps.logger, + resolveBinary: this.resolveBinary, + }); + this.store = new StreamStore(deps.pool); + this.streams = new StreamRecorder(this.store, { + // A round the engine ran on its own settles by going quiet; the view + // learns of it the same way it learns of every other stream write. + onAutonomousSettled: (agentId) => deps.publishHarness(agentId, true), + }); + this.usage = new UsageRecorder(deps.pool); + this.driver.onEvent((event) => { + const prior = this.queues.get(event.agentId) ?? Promise.resolve(); + const next = prior.then(() => this.onEvent(event)); + this.queues.set(event.agentId, next); + void next.finally(() => { + if (this.queues.get(event.agentId) === next) { + this.queues.delete(event.agentId); + } + }); + }); + } + + isRunning(agentId: string): boolean { + return this.driver.isRunning(agentId); + } + + isBusy(agentId: string): boolean { + return this.running.has(agentId) || this.pendingOf(agentId).length > 0; + } + + private pendingOf(agentId: string): Pending[] { + return this.pending.get(agentId) ?? []; + } + + listQueued(agentId: string): QueuedPrompt[] { + return this.pendingOf(agentId).map(({ id, source, createdAt }) => ({ + id, + source, + createdAt, + })); + } + + /** + * Drop a prompt that has not started. Its `started` rejects, so a chat + * message settles as not delivered rather than pending forever. + */ + removeQueued(agentId: string, id: string): boolean { + const list = this.pendingOf(agentId); + const index = list.findIndex((item) => item.id === id); + if (index === -1) return false; + const [item] = list.splice(index, 1); + if (list.length === 0) this.pending.delete(agentId); + item.failStarted(new Error("Removed from the queue before it started.")); + item.markSettled(); + this.deps.publishHarness(agentId); + return true; + } + + promoteQueued(agentId: string, id: string): boolean { + const list = this.pendingOf(agentId); + const index = list.findIndex((item) => item.id === id); + if (index === -1) return false; + if (index > 0) { + const [item] = list.splice(index, 1); + list.unshift(item); + this.deps.publishHarness(agentId); + } + return true; + } + + /** + * Cancel the running turn. It settles as cancelled and the next queued + * prompt starts. Nothing running: nothing happens. + */ + async interrupt(agentId: string): Promise { + if (!this.running.has(agentId) || !this.driver.isRunning(agentId)) { + return false; + } + await this.driver.cancel(agentId); + return true; + } + + async sendQueuedNow(agentId: string, id: string): Promise { + if (!this.promoteQueued(agentId, id)) return false; + await this.interrupt(agentId); + return true; + } + + getConfigOptions(agentId: string): HarnessConfigOption[] | null { + const options = this.driver.getConfigOptions(agentId); + return options ? (options as HarnessConfigOption[]) : null; + } + + getSessionStartedAt(agentId: string): string | null { + return this.driver.getSessionStartedAt(agentId); + } + + getCommands(agentId: string): HarnessCommand[] | null { + const commands = this.driver.getCommands(agentId); + return commands + ? commands.map((c) => ({ + name: c.name, + description: c.description, + ...(c.input ? { input: { hint: c.input.hint } } : {}), + })) + : null; + } + + async setConfigOption( + agentId: string, + configId: string, + value: string + ): Promise { + const options = await this.driver.setConfigOption(agentId, configId, value); + const ctx = this.context.get(agentId); + if (ctx && isModelOption(options as HarnessConfigOption[], configId)) { + ctx.model = value; + await this.deps.setAgentModel?.(agentId, `${ctx.engine}/${value}`); + } + // Another client's picker shows the switch without waiting for a poll. + this.deps.publishHarness(agentId, true); + return options as HarnessConfigOption[]; + } + + /** How long after a restart cut a turn the agent is still told to resume it. */ + static readonly RESTART_RESUME_WINDOW_MS = 60 * 60_000; + + async start(agentId: string): Promise<{ resumed: boolean }> { + const agent = await this.deps.getAgent(agentId); + if (!agent || agent.type !== "dispatch") { + throw new Error(`${agentId} is not a Dispatch Harness agent`); + } + const { engine, model } = splitModelId( + agent.model ?? DEFAULT_HARNESS_MODEL + ); + // Rows a previous process left open (restart mid-turn) settle first, + // so the view never shows a turn that can no longer finish. + await this.streams.reconcile(agentId); + const jobRunId = (await this.deps.activeJobRunIdFor?.(agentId)) ?? null; + const persona = await this.deps.personaPromptFor(agent, jobRunId); + const mediaDir = resolveMediaDir( + agentId, + agent.mediaDir, + this.deps.config.mediaRoot + ); + const env = buildChildEnv({ + agentId, + mediaDir, + config: this.deps.config, + engine, + }); + const spec = engineSpecFor(engine, model, await this.binsFor(engine, env)); + this.streams.setCwd(agentId, agent.cwd); + let session: { sessionId: string; resumed: boolean }; + try { + session = await this.driver.start({ + agentId, + cwd: agent.cwd, + engine: spec, + systemPromptAppend: + spec.personaDelivery === "system_prompt" ? persona : null, + mcp: { + url: dispatchMcpUrl(this.deps.config, agentId, jobRunId ?? undefined), + token: jobRunId + ? createJobMcpToken(this.deps.config.authToken, jobRunId, agentId) + : createAgentMcpToken(this.deps.config.authToken, agentId), + }, + sessionId: agent.cliSessionId ?? null, + env, + }); + } catch (err) { + // An auth_required failure at launch names the engine, so the caller's + // status message tells the operator what to run instead of just that + // the start failed. + const login = loginFailureMessage(engine, err); + throw login ? new Error(login) : err; + } + const { sessionId, resumed } = session; + this.context.set(agentId, { sessionId, engine, model }); + // An engine that takes the persona in its first prompt gets it once: on a + // fresh session, or on a resumed session that never ran a turn (the + // process stopped between opening the session and its first prompt). A + // resumed session with a turn behind it has the persona in its history. + // The read is agent-scoped, not session-scoped: turn rows carry no + // session id, so an agent whose earlier session ran turns but whose + // replacement session was stopped before its first turn keeps its + // history and gets no persona; a deliberate trade, not an oversight. + const neverRan = (await this.store.lastTurnSettlement(agentId)) === null; + if (spec.personaDelivery === "first_prompt" && (!resumed || neverRan)) { + this.pendingPersona.set(agentId, persona); + } else { + this.pendingPersona.delete(agentId); + } + if (model !== "default" && !spec.modelFixedAtLaunch) { + await this.applyModel(agentId, model); + } + await this.deps.setCliSessionId(agentId, sessionId); + await this.deps.setLatestEvent(agentId, { + type: "idle", + // A stored session that came back as a fresh one is not a first + // start: the engine could not resume (Gemini CLI answers session/ + // resume with "method not found"), so its own history is gone even + // though Dispatch still has every turn. + message: resumed + ? "Harness session resumed." + : agent.cliSessionId + ? "Session restarted; this engine cannot resume, so Dispatch keeps the turns." + : "Harness session started.", + }); + // The session's options exist from here: the picker can read them. + this.deps.publishHarness(agentId, true); + // A fresh session gets the launch prompt as its first turn; so does a + // resumed one that never ran a turn. A resumed session with a turn + // behind it already had it. + if (!agent.cliSessionId || neverRan) { + const first = await this.deps.launchPromptFor(agentId); + if (first) { + this.enqueuePrompt(agentId, first).settled.catch((err: unknown) => { + this.deps.logger.warn({ err, agentId }, "harness first turn failed"); + }); + } + } + return { resumed }; + } + + /** + * The binaries a spec needs, resolved to absolute paths: an engine's + * adapter finds the host CLI through an env var, and the service's PATH + * is not a login shell's. The host codex is only named when configured. + */ + private async binsFor( + engine: HarnessEngineId, + env: NodeJS.ProcessEnv + ): Promise { + const c = this.deps.config; + return { + claudeHarnessBin: c.claudeHarnessBin, + codexHarnessBin: c.codexHarnessBin, + geminiBin: c.geminiBin, + opencodeBin: c.opencodeBin, + claudeBin: + engine === "claude" + ? await this.resolveBinary(c.claudeBin, env) + : c.claudeBin, + codexBin: + engine === "codex" && process.env.DISPATCH_CODEX_BIN + ? await this.resolveBinary(c.codexBin, env) + : null, + }; + } + + /** A stored model that is not the engine's default is applied through its model option. */ + private async applyModel(agentId: string, model: string): Promise { + const options = this.driver.getConfigOptions(agentId) ?? []; + const option = modelOptionOf(options as HarnessConfigOption[]); + if (!option) { + this.deps.logger.warn( + { agentId, model }, + "the engine publishes no model option; keeping its default model" + ); + return; + } + try { + await this.driver.setConfigOption(agentId, option.id, model); + } catch (err) { + this.deps.logger.warn( + { err, agentId, model }, + "the engine refused the stored model; keeping its default" + ); + } + } + + /** + * Bring back every harness agent recorded as running after a server + * restart. The stored session id resumes; an agent that cannot come back + * is marked failed rather than left "running" with nothing behind it. + */ + async restoreRunning(): Promise<{ restored: string[]; failed: string[] }> { + const restored: string[] = []; + const failed: string[] = []; + for (const id of await this.deps.listRunningAgentIds()) { + try { + // start() overwrites latestEvent with "session resumed", so what + // the agent last said about itself is read before that. + const lastSaid = (await this.deps.getAgent(id))?.latestEvent?.type; + const { resumed } = await this.start(id); + restored.push(id); + if (resumed && (await this.shouldResumeAfterRestart(id, lastSaid))) { + this.enqueuePrompt(id, RESTART_PROMPT).settled.catch( + (err: unknown) => { + this.deps.logger.warn( + { err, agentId: id }, + "harness restart follow-up turn failed" + ); + } + ); + } + } catch (err) { + failed.push(id); + // start() already maps an auth_required failure to the login + // message before it reaches here; re-derive the engine and map + // again so the stored message names it even if that ever changes. + const engine = await this.engineFor(id); + const login = engine ? loginFailureMessage(engine, err) : null; + const message = login ?? (err as Error).message; + this.deps.logger.warn( + { err, agentId: id }, + "harness agent could not be restored at boot" + ); + await this.deps + .markStartFailed(id, message.slice(0, MESSAGE_MAX)) + .catch(() => {}); + } + } + return { restored, failed }; + } + + private async engineFor(agentId: string): Promise { + try { + const agent = await this.deps.getAgent(agentId); + if (!agent) return null; + return splitModelId(agent.model ?? DEFAULT_HARNESS_MODEL).engine; + } catch { + return null; + } + } + + /** + * A turn the restart cut short continues only when the cut is recent + * (an agent idle for days is not billed a turn at every boot), the + * session really resumed (a fresh session has no memory to continue + * from), and the agent had not already declared itself done, blocked, + * or waiting on someone. + */ + private async shouldResumeAfterRestart( + agentId: string, + lastSaid: string | undefined + ): Promise { + const cutAt = await this.streams.lastTurnInterruptedByRestartAt(agentId); + if (!cutAt) return false; + if ( + Date.now() - cutAt.getTime() > + HarnessSupervisor.RESTART_RESUME_WINDOW_MS + ) { + return false; + } + return ( + lastSaid !== "done" && + lastSaid !== "blocked" && + lastSaid !== "waiting_user" + ); + } + + /** + * Queue one turn. `started` resolves when the turn begins (earlier turns + * for the agent have settled) and rejects if the prompt is removed or the + * agent stops first; `settled` resolves when it ends and never rejects. + */ + enqueuePrompt( + agentId: string, + text: string + ): { started: Promise; settled: Promise } { + const source = parsePromptSource(text); + let markStarted: () => void = () => {}; + let failStarted: (err: Error) => void = () => {}; + const started = new Promise((resolve, reject) => { + markStarted = resolve; + failStarted = reject; + }); + // A caller that only waits on `settled` must not turn a removal into + // an unhandled rejection. + started.catch(() => {}); + let markSettled: () => void = () => {}; + const settled = new Promise((resolve) => { + markSettled = resolve; + }); + const item: Pending = { + id: source.source === "chat" ? source.chatMessageId : `q_${randomUUID()}`, + source, + createdAt: new Date().toISOString(), + text, + started, + markStarted, + failStarted, + settled, + markSettled, + }; + const list = this.pendingOf(agentId); + list.push(item); + this.pending.set(agentId, list); + this.pump(agentId); + // Still waiting: no stream write announces it, so tell the feed here + // and the view lists it at once. + if (this.pendingOf(agentId).includes(item)) + this.deps.publishHarness(agentId); + return { started, settled }; + } + + /** Start the next queued prompt when nothing runs; runs itself again after. */ + private pump(agentId: string): void { + // Shutdown has begun: what is queued stays queued, so a chat message + // keeps its undelivered row for the next boot to redeliver rather than + // starting a turn the teardown is about to cut. + if (this.shuttingDown) return; + if (this.running.has(agentId)) return; + const list = this.pendingOf(agentId); + const next = list.shift(); + if (list.length === 0) this.pending.delete(agentId); + if (!next) return; + // The slot is claimed here, synchronously, because `isBusy` and the + // queue order read it. `started` is not resolved here: it waits for the + // engine to accept the prompt, so a chat message is never recorded as + // delivered to a child that has already gone away. + this.running.set(agentId, next); + void this.runTurn( + agentId, + next.text, + () => this.pendingOf(agentId).length === 0, + next + ) + .catch(() => {}) + .finally(() => { + if (this.running.get(agentId) === next) this.running.delete(agentId); + next.markSettled(); + this.pump(agentId); + }); + } + + /** + * Drop everything queued for the agent, failing each prompt's start. + * With `keepChat`, chat messages are dropped from memory but their + * `started` is left pending: the chat row stays `delivered: null`, and + * the next boot delivers it again (see ChatService.redeliverPending). + */ + private flushQueued( + agentId: string, + reason: string, + opts: { keepChat?: boolean } = {} + ): void { + const list = this.pendingOf(agentId); + this.pending.delete(agentId); + for (const item of list) { + if (!(opts.keepChat && item.source.source === "chat")) { + item.failStarted(new Error(reason)); + } + item.markSettled(); + } + if (list.length > 0) this.deps.publishHarness(agentId); + } + + async prompt(agentId: string, text: string): Promise { + await this.enqueuePrompt(agentId, text).settled; + } + + private async runTurn( + agentId: string, + text: string, + isLastQueued: () => boolean, + item?: Pending + ): Promise { + let startedAt: string | null = null; + const persona = this.pendingPersona.get(agentId); + if (persona !== undefined) { + this.pendingPersona.delete(agentId); + text = `${persona}\n\n${text}`; + } + try { + await this.deps.setLatestEvent(agentId, { + type: "working", + message: "Working on the latest message.", + }); + startedAt = + (await this.deps.getAgent(agentId))?.latestEvent?.updatedAt ?? null; + await this.driver.prompt(agentId, text, () => item?.markStarted()); + await this.drained(agentId); + if (isLastQueued()) { + await this.settle(agentId, startedAt, { + type: "idle", + message: "Turn finished.", + }); + } + } catch (err) { + const message = (err as Error).message; + this.deps.logger.warn({ err, agentId }, "harness prompt failed"); + // A prompt that never reached the engine leaves its caller a + // rejection, so a chat message settles as not delivered instead of + // waiting for ever. A no-op once the engine accepted the prompt. + item?.failStarted(err as Error); + if (isLastQueued()) { + await this.settle(agentId, startedAt, { + type: "idle", + message: `Turn failed: ${message}`.slice(0, MESSAGE_MAX), + }).catch(() => {}); + } + } + } + + /** + * The settle-time status yields to a terminal status the agent set during + * the turn: a reviewer's `done`, a question's `waiting_user`, a `blocked`. + * Those come from dispatch_event inside the turn and would otherwise be + * overwritten milliseconds later. + */ + private async settle( + agentId: string, + startedAt: string | null, + input: { type: AgentLatestEventType; message: string } + ): Promise { + const current = (await this.deps.getAgent(agentId))?.latestEvent; + const terminal = + current && + (current.type === "done" || + current.type === "blocked" || + current.type === "waiting_user"); + if (terminal && (startedAt === null || current.updatedAt > startedAt)) { + return; + } + await this.deps.setLatestEvent(agentId, input); + } + + async stop( + agentId: string, + opts: { keepChat?: boolean } = {} + ): Promise { + this.flushQueued( + agentId, + "The agent stopped before the message was sent.", + opts + ); + await this.driver.stop(agentId); + this.context.delete(agentId); + this.turnReply.delete(agentId); + this.pendingPersona.delete(agentId); + } + + /** Server shutdown: stop every child through the teardown ladder, bounded. */ + async stopAll(): Promise { + const ids = this.driver.liveAgentIds(); + if (ids.length === 0) return; + this.shuttingDown = true; + // A turn still running is the restart's doing, not the agent's: mark it + // so the next boot knows to resume it, before the exit settles it as + // merely cancelled. Bounded: a slow database must not hold the + // shutdown past launchd's patience; the next boot settles the row too. + await Promise.race([ + Promise.allSettled( + ids + .filter((id) => this.running.has(id)) + .map((id) => this.streams.reconcile(id)) + ), + new Promise((resolve) => setTimeout(resolve, RECONCILE_TIMEOUT_MS)), + ]); + await Promise.race([ + Promise.allSettled(ids.map((id) => this.stop(id, { keepChat: true }))), + new Promise((resolve) => setTimeout(resolve, STOP_ALL_TIMEOUT_MS)), + ]); + // Whatever the ladder did not finish, end here and now. Returning with a + // child still live hands the caller's process.exit() an orphan holding + // full-access permissions and a live MCP token. + const killed = this.driver.killAll(); + if (killed.length > 0) { + this.deps.logger.warn( + { agentIds: killed }, + "harness children did not exit in time and were killed" + ); + } + } + + private publishNow(agentId: string, config: boolean): void { + const timer = this.publishTimers.get(agentId); + if (timer) { + clearTimeout(timer); + this.publishTimers.delete(agentId); + } + this.deps.publishHarness(agentId, config); + } + + private publishCoalesced(agentId: string): void { + if (this.publishTimers.has(agentId)) return; + const timer = setTimeout(() => { + this.publishTimers.delete(agentId); + this.deps.publishHarness(agentId); + }, FLUSH_INTERVAL_MS); + timer.unref?.(); + this.publishTimers.set(agentId, timer); + } + + private async drained(agentId: string): Promise { + await this.queues.get(agentId); + } + + private async onEvent(event: DriverEvent): Promise { + try { + await this.streams.handle(event); + const ctx = this.context.get(event.agentId); + // The usage table's model column is what the token-by-model report + // groups on: without the engine, every engine's "default" model + // collapses into one row. + if (ctx) { + await this.usage.handle(event, { + sessionId: ctx.sessionId, + model: `${ctx.engine}/${ctx.model}`, + }); + } + // A turn boundary or the child going away changes the running state, so + // it publishes at once; a streamed update rides the trailing timer. + if (event.type === "turn" || event.type === "exit") { + this.publishNow(event.agentId, true); + } else { + this.publishCoalesced(event.agentId); + } + // Claude Code has no auth_required error; it answers a turn saying to + // run /login instead. Accumulate what this turn says, so the check + // runs against the opening of the whole reply rather than one chunk, + // and note a tool call, which rules the answer out. + if (event.type === "turn" && event.state === "started") { + if (ctx?.engine === "claude") { + this.turnReply.set(event.agentId, { text: "", toolCall: false }); + } else { + this.turnReply.delete(event.agentId); + } + } + const reply = + event.type === "update" ? this.turnReply.get(event.agentId) : undefined; + if (reply && event.type === "update") { + if (event.update.sessionUpdate === "tool_call") { + reply.toolCall = true; + } else if ( + event.update.sessionUpdate === "agent_message_chunk" && + event.update.content.type === "text" && + reply.text.length < LOGIN_REPLY_MAX_CHARS + ) { + reply.text = (reply.text + event.update.content.text).slice( + 0, + LOGIN_REPLY_MAX_CHARS + ); + } + } + // Read at the boundary, before the turn's own handling below, so the + // exit that stop() triggers is not read by the unexpected-exit branch + // that follows. + const settledReply = + event.type === "turn" && event.state === "settled" + ? this.turnReply.get(event.agentId) + : undefined; + if (settledReply) this.turnReply.delete(event.agentId); + if ( + settledReply && + !settledReply.toolCall && + LOGIN_REPLY_RE.test(settledReply.text.trim()) + ) { + // stop() marks its live entry as stopping before it closes the + // session, so the exit event that follows carries expected: true + // and never reaches the unexpected-exit branch below, so this + // message is the one that stands. + await this.driver.stop(event.agentId); + const message = "Claude Code is not logged in on the server."; + if (this.deps.markExited) { + await this.deps.markExited(event.agentId, message); + } else { + await this.deps.setLatestEvent(event.agentId, { + type: "blocked", + message, + }); + } + } + if (event.type === "exit" && !event.expected) { + this.context.delete(event.agentId); + this.turnReply.delete(event.agentId); + // Any unexpected exit, code 0 included: a "running" agent over a + // dead child takes every prompt to a 409. + const message = `The engine exited (${event.code ?? event.signal ?? "unknown"}); press Start to relaunch.`; + if (this.deps.markExited) { + await this.deps.markExited(event.agentId, message); + } else { + await this.deps.setLatestEvent(event.agentId, { + type: "blocked", + message, + }); + } + } + } catch (err) { + this.deps.logger.warn( + { err, agentId: event.agentId }, + "harness event handling failed" + ); + } + } +} diff --git a/apps/server/src/agents/harness/usage-recorder.ts b/apps/server/src/agents/harness/usage-recorder.ts new file mode 100644 index 000000000..f9839eca4 --- /dev/null +++ b/apps/server/src/agents/harness/usage-recorder.ts @@ -0,0 +1,45 @@ +import type { Queryable } from "../../chat/store.js"; +import type { DriverEvent } from "./driver.js"; + +/** + * ACP reports cumulative session usage on each prompt response, so every + * settled turn rewrites the totals for (agent, session, model) and bumps the + * turn count. Same table and conflict key the log-scraping harvester uses + * for Claude and Codex, so the token panel needs no new query. + */ +const UPSERT_SQL = `INSERT INTO agent_token_usage + (agent_id, session_id, model, input_tokens, cache_creation_tokens, cache_read_tokens, + output_tokens, message_count, session_start, session_end) + VALUES ($1, $2, $3, $4, $5, $6, $7, 1, NOW(), NOW()) + ON CONFLICT (agent_id, session_id, model) + DO UPDATE SET + input_tokens = EXCLUDED.input_tokens, + cache_creation_tokens = EXCLUDED.cache_creation_tokens, + cache_read_tokens = EXCLUDED.cache_read_tokens, + output_tokens = EXCLUDED.output_tokens, + message_count = agent_token_usage.message_count + 1, + session_end = NOW(), + harvested_at = NOW()`; + +export class UsageRecorder { + constructor(private readonly db: Queryable) {} + + async handle( + event: DriverEvent, + ctx: { sessionId: string; model: string } + ): Promise { + if (event.type !== "turn" || event.state !== "settled" || !event.usage) { + return; + } + const u = event.usage; + await this.db.query(UPSERT_SQL, [ + event.agentId, + ctx.sessionId, + ctx.model, + u.inputTokens ?? 0, + u.cachedWriteTokens ?? 0, + u.cachedReadTokens ?? 0, + u.outputTokens ?? 0, + ]); + } +} diff --git a/apps/server/src/agents/harness/usage.ts b/apps/server/src/agents/harness/usage.ts new file mode 100644 index 000000000..732781528 --- /dev/null +++ b/apps/server/src/agents/harness/usage.ts @@ -0,0 +1,119 @@ +import { + HARNESS_ENGINES, + harnessEngineOf, + type HarnessUsageAgent, + type HarnessUsageEngine, + type HarnessUsageReport, + type UsageBudgets, +} from "@dispatch/shared"; + +import type { Queryable } from "../../chat/store.js"; + +/** + * What the harness engines have used this month. Tokens come from + * `agent_token_usage`, which the usage recorder fills from each prompt + * response's cumulative counts. Cost comes from the newest turn row that + * carries a `usage.cost`: an engine reports its running total for the + * current session, so this is the current session's spend, and a session + * the agent ran earlier in the month is not added to it. + */ + +export function monthStartUtc(now: Date = new Date()): Date { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); +} + +type AgentRow = { + id: string; + name: string; + model: string | null; + tokens: string | number; + cost_amount: string | number | null; + cost_currency: string | null; +}; + +const AGENTS_SQL = ` + WITH tokens AS ( + SELECT agent_id, + SUM(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens) AS tokens + FROM agent_token_usage + WHERE session_end >= $1 + GROUP BY agent_id + ), + cost AS ( + SELECT DISTINCT ON (agent_id) agent_id, + (payload->'usage'->'cost'->>'amount')::float8 AS amount, + payload->'usage'->'cost'->>'currency' AS currency + FROM agent_stream_events + WHERE kind = 'turn' AND created_at >= $1 + AND payload->'usage'->'cost' IS NOT NULL + ORDER BY agent_id, seq DESC + ) + SELECT a.id, a.name, a.model, + COALESCE(t.tokens, 0) AS tokens, + c.amount AS cost_amount, c.currency AS cost_currency + FROM agents a + LEFT JOIN tokens t ON t.agent_id = a.id + LEFT JOIN cost c ON c.agent_id = a.id + WHERE a.type = 'dispatch' AND a.deleted_at IS NULL`; + +function toAgent(row: AgentRow): HarnessUsageAgent { + return { + agentId: row.id, + name: row.name, + tokens: Number(row.tokens), + costUsd: + row.cost_amount !== null && row.cost_currency === "USD" + ? Number(row.cost_amount) + : null, + }; +} + +export async function loadUsageReport( + db: Queryable, + budgets: UsageBudgets, + now: Date = new Date() +): Promise { + const monthStart = monthStartUtc(now); + const result = await db.query(`${AGENTS_SQL} ORDER BY a.name`, [ + monthStart, + ]); + const engines: HarnessUsageEngine[] = HARNESS_ENGINES.map((engine) => ({ + ...engine, + tokens: 0, + costUsd: null, + budgetUsd: engine.reportsCost ? (budgets[engine.id] ?? null) : null, + agents: [], + })); + for (const row of result.rows) { + // A row with no model counts under the default engine, which is the + // engine its child runs; null here is only an unknown engine id. + const engine = harnessEngineOf(row.model); + if (!engine) continue; + const bucket = engines.find((e) => e.id === engine.id); + if (!bucket) continue; + const agent = toAgent(row); + bucket.agents.push(agent); + bucket.tokens += agent.tokens; + if (agent.costUsd !== null) { + bucket.costUsd = (bucket.costUsd ?? 0) + agent.costUsd; + } + } + return { + generatedAt: now.toISOString(), + monthStart: monthStart.toISOString(), + engines, + }; +} + +export async function loadAgentUsage( + db: Queryable, + agentId: string, + now: Date = new Date() +): Promise { + const result = await db.query(`${AGENTS_SQL} AND a.id = $2`, [ + monthStartUtc(now), + agentId, + ]); + const row = result.rows[0]; + return row ? toAgent(row) : null; +} diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index cdc88739d..5ad0c8050 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -25,9 +25,13 @@ import { import { getActivePersonality } from "../db/personalities.js"; import { isTrimmedLaunchGuidanceEnabled } from "../launch-guidance-settings.js"; import { isChatSurfaceEnabled } from "../chat-surface-settings.js"; +import { getOfferedAgentTypes } from "../agent-type-settings.js"; import { findCodexSessionId } from "./codex-sessions.js"; import { harvestTokenUsage } from "./token-harvester.js"; import { errorMessage } from "../shared/lib/error-message.js"; +import { buildHarnessPersona } from "./harness/persona.js"; +import type { HarnessSupervisor } from "./harness/supervisor.js"; +import type { AgentPromptTarget } from "./types.js"; import { beginArchive as beginArchiveImpl, executeArchive as executeArchiveImpl, @@ -310,6 +314,7 @@ export class AgentManager { private readonly runtime: AgentRuntime; private readonly reconciler: Reconciler; private diffStatsRefresher: DiffStatsRefresherHandle | null = null; + private harnessSupervisor: HarnessSupervisor | null = null; private launchContextRecorder: LaunchContextRecorder | null = null; private readonly agentCreatedListeners: Array<(agent: AgentRecord) => void> = []; @@ -335,6 +340,136 @@ export class AgentManager { }); } + /** + * Inject the harness supervisor. Wired post-construction like the other + * collaborators; without it a harness agent fails setup loudly rather than + * sitting in a shell with no harness behind it. + */ + attachHarnessSupervisor(supervisor: HarnessSupervisor): void { + this.harnessSupervisor = supervisor; + } + + /** + * Where a prompt for this agent goes: the ACP child for a harness agent, + * the tmux pane for a CLI agent, or nowhere in inert mode. One agent read. + */ + async getPromptTarget(id: string): Promise { + const agent = await this.getRequiredAgent(id); + if (agent.type === "dispatch") { + if (!this.harnessSupervisor?.isRunning(id)) { + throw new AgentError( + "The harness is not running for this agent; the prompt cannot be delivered.", + 409 + ); + } + return { kind: "harness", busy: this.harnessSupervisor.isBusy(id) }; + } + const access = await this.terminalAccessFor(agent); + return access.mode === "tmux" + ? { kind: "tmux", sessionName: access.sessionName } + : { kind: "inert", message: access.message }; + } + + /** + * Queue one harness turn. `started` resolves when it begins (after any + * turn already running), `settled` when it ends. See + * HarnessSupervisor.enqueuePrompt. + */ + promptHarness( + id: string, + text: string + ): { started: Promise; settled: Promise } { + if (!this.harnessSupervisor) { + throw new AgentError("The harness supervisor is not attached.", 500); + } + return this.harnessSupervisor.enqueuePrompt(id, text); + } + + /** Harness agents the last process left running; the supervisor restores them at boot. */ + async listRunningHarnessAgentIds(): Promise { + const result = await this.pool.query<{ id: string }>( + `SELECT id FROM agents + WHERE type = 'dispatch' AND status = 'running' AND deleted_at IS NULL + ORDER BY created_at` + ); + return result.rows.map((row) => row.id); + } + + /** The harness child died on its own: the agent cannot stay "running" over it. */ + async markHarnessExited(id: string, message: string): Promise { + await this.setAgentStatus(id, "error", message); + await this.setSystemLatestEvent(id, { + type: "blocked", + message: message.slice(0, 200), + metadata: { source: "system", phase: "exit" }, + }); + } + + async markHarnessStartFailed(id: string, message: string): Promise { + await this.setAgentStatus(id, "error", message); + await this.setSystemLatestEvent(id, { + type: "blocked", + message: `The harness did not come back after restart: ${message}`.slice( + 0, + 200 + ), + metadata: { source: "system", phase: "start" }, + }); + } + + async buildHarnessPersonaFor( + agent: AgentRecord, + jobRunId?: string + ): Promise { + const inputs = await this.launchGuidanceInputsFor(agent, jobRunId); + return buildHarnessPersona({ + agent, + ...inputs, + jobRunId: jobRunId ?? null, + }); + } + + /** + * The per-launch inputs every harness's guidance is built from: the active + * personality (never for persona or assisted-update agents), the guidance + * flags, and whether to suggest a session rename. + */ + private async launchGuidanceInputsFor( + agent: Pick, + jobRunId?: string + ): Promise<{ + personalityPrompt: string | null; + trimmedGuidance: boolean; + chatSurface: boolean; + suggestSessionRename: boolean; + }> { + // Same rule as the pane launch: a persona, a job run, or an assisted + // update gets no personality. + const personality = + agent.persona || jobRunId || agent.role === "assisted_update" + ? null + : await getActivePersonality(this.pool); + const { trimmedGuidance, chatSurface } = await readLaunchGuidanceFlags( + this.pool + ); + return { + personalityPrompt: personality?.prompt ?? null, + trimmedGuidance, + chatSurface, + suggestSessionRename: shouldSuggestSessionRename(agent.name, agent.id, { + persona: agent.persona, + jobRunId, + }), + }; + } + + async setCliSessionId(id: string, cliSessionId: string): Promise { + await this.pool.query( + `UPDATE agents SET cli_session_id = $2, updated_at = NOW() WHERE id = $1`, + [id, cliSessionId] + ); + } + /** Register a callback invoked after every upsertLatestEvent. */ onLatestEvent(listener: AgentEventListener): void { this.eventBus.subscribe(listener); @@ -475,6 +610,19 @@ export class AgentManager { } async createAgent(input: CreateAgentInput): Promise { + // Gated here rather than only at the routes: template launches and job + // runs take an agent type straight from their request body and reach + // this method without consulting the offered list, so `dispatch` was + // creatable through them with the Dispatch Harness flag off. This is the + // one place every creation path goes through. + // Before prepareCreateInputs, which makes the agent's media directory: + // a rejected create must not leave one behind. The default matches the + // one it applies. + const type: AgentType = input.type ?? "codex"; + const offered = await getOfferedAgentTypes(this.pool); + if (!offered.includes(type)) { + throw new Error(`${type} agents are disabled in settings.`); + } const p = await this.prepareCreateInputs(input); await this.insertAgentRecord(p, input); @@ -525,10 +673,15 @@ export class AgentManager { ); // Terminal sessions have no CLI to chat with, so they get no post at all. const recorder = p.type === "terminal" ? null : this.launchContextRecorder; + // A harness agent reads its launch prompt from the Chat post (it takes + // no launch argument), so the post is durable for it whatever the flag + // says. A job run keeps Chat quiet for CLI agents (the pane carries the + // job scaffolding), but a harness job still needs the post: it is the + // only way the job prompt becomes the agent's first turn. const wantsEnvelope = recorder !== null && - launchGuidanceFlags.chatSurface && - !input.jobRunId && + (launchGuidanceFlags.chatSurface || p.type === "dispatch") && + (!input.jobRunId || p.type === "dispatch") && !inertRuntime; const launchPostId = randomUUID(); const launchContextInput = recorder @@ -616,7 +769,21 @@ export class AgentManager { return { id: launchPostId, agentId: p.id, - text: input.launchContext?.prompt, + // A harness agent takes no launch argument: its first turn is the + // Chat post, so a launch that only carries `initialPrompt` (a persona + // kickoff, an MCP launch) still gets one. CLI agents type it in. + text: + input.launchContext?.prompt ?? + (p.type === "dispatch" ? input.initialPrompt : undefined), + // The MCP launch header and a rendered template ride initialPrompt; + // a harness first turn must carry them even though Chat shows the raw + // prompt the launcher wrote. + ...(p.type === "dispatch" && + input.initialPrompt && + input.launchContext?.prompt && + input.initialPrompt !== input.launchContext.prompt + ? { deliveryText: input.initialPrompt } + : {}), files: initialMedia.map((media) => ({ mediaId: media.mediaId })), links: input.launchContext?.links ?? [], pins: p.initialPins.map((pin) => ({ @@ -743,7 +910,14 @@ export class AgentManager { const id = this.newAgentId(); const type: AgentType = input.type ?? "codex"; const role: AgentRole = input.role ?? "standard"; - const fullAccess = input.fullAccess ?? false; + // Every Dispatch Harness engine launches in its most permissive mode + // (skip-permissions, agent-full-access, yolo, auto-allow), so the stored + // flag has to say so however the agent was asked for. Left at the + // caller's word, the sidebar card read "Sandboxed" for the most + // permissive agent on the board. This is the one place every create path + // passes through: the HTTP route, dispatch_launch_agent, a persona + // review, a job and a template. + const fullAccess = type === "dispatch" || (input.fullAccess ?? false); const fullAccessArg = type === "claude" ? CLAUDE_FULL_ACCESS_ARG @@ -1114,12 +1288,32 @@ export class AgentManager { // upsert) carries the populated context. await this.populateGitContext(id); - await this.setSystemLatestEvent( - id, - agent.type === "terminal" - ? { type: "idle", message: "Terminal session started." } - : { type: "idle", message: "Session started." } - ); + if (agent.type === "dispatch") { + // The pane is only a shell; the harness is the ACP child the + // supervisor starts now that the worktree exists. + if (!this.harnessSupervisor) { + throw new AgentError("The harness supervisor is not attached.", 500); + } + try { + await this.harnessSupervisor.start(id); + } catch (error) { + const message = errorMessage(error); + await this.setAgentStatus(id, "error", message); + await this.setSystemLatestEvent(id, { + type: "blocked", + message: `The harness failed to start: ${message}`.slice(0, 200), + metadata: { source: "system", phase: "start" }, + }); + throw new AgentError(`The harness failed to start: ${message}`, 500); + } + } else { + await this.setSystemLatestEvent( + id, + agent.type === "terminal" + ? { type: "idle", message: "Terminal session started." } + : { type: "idle", message: "Session started." } + ); + } // Clean up setup script const setupScriptPath = `/tmp/dispatch_setup_${id}.sh`; @@ -1188,6 +1382,28 @@ export class AgentManager { const hasSession = await this.runtime.hasSession(tmuxSession); if (hasSession) { + // A harness agent's pane is only a shell and outlives the harness + // child; an attached shell is not a running harness. Start (or + // restart) it. + if (agent.type === "dispatch" && !this.harnessSupervisor?.isRunning(id)) { + if (!this.harnessSupervisor) { + throw new AgentError("The harness supervisor is not attached.", 500); + } + try { + await this.setAgentStatus(id, "running", null, tmuxSession); + await this.harnessSupervisor.start(id); + } catch (error) { + const message = errorMessage(error); + await this.setAgentStatus(id, "error", message); + await this.setSystemLatestEvent(id, { + type: "blocked", + message: `The harness failed to start: ${message}`.slice(0, 200), + metadata: { source: "system", phase: "start" }, + }); + throw new AgentError(`The harness failed to start: ${message}`, 500); + } + return (await this.getAgent(id)) as AgentRecord; + } await this.setAgentStatus(id, "running", null, tmuxSession); await this.setSystemLatestEvent(id, { type: "idle", @@ -1232,13 +1448,12 @@ export class AgentManager { // bash script. We do it once here so both runtimes are happy.) await mkdir(mediaDir, { recursive: true }); - const personality = - agent.persona || agent.role === "assisted_update" - ? null - : await getActivePersonality(this.pool); - const { trimmedGuidance, chatSurface } = await readLaunchGuidanceFlags( - this.pool - ); + const { + personalityPrompt, + trimmedGuidance, + chatSurface, + suggestSessionRename, + } = await this.launchGuidanceInputsFor(agent); const agentCommand = buildAgentCommand( this.config, @@ -1251,13 +1466,11 @@ export class AgentManager { { cliSessionId: cliSessionId ?? undefined, resume: shouldResume, - suggestSessionRename: shouldSuggestSessionRename(agent.name, id, { - persona: agent.persona, - }), + suggestSessionRename, autoReview: !agent.persona && (agent.autoReview ?? false), trimmedGuidance, chatSurface, - personalityPrompt: personality?.prompt ?? null, + personalityPrompt, model: agent.model ?? undefined, } ); @@ -1274,6 +1487,14 @@ export class AgentManager { // predate inline-populate still get a fresh context (and any drift // from external git activity gets picked up at start time). await this.populateGitContext(id); + if (agent.type === "dispatch") { + if (!this.harnessSupervisor) { + throw new Error("The harness supervisor is not attached."); + } + // Resumes the stored session id; the supervisor sets the idle event. + await this.harnessSupervisor.start(id); + return (await this.getAgent(id)) as AgentRecord; + } await this.setSystemLatestEvent( id, agent.type === "terminal" @@ -1304,8 +1525,20 @@ export class AgentManager { } async getTerminalAccess(id: string): Promise { - const agent = await this.getRequiredAgent(id); - if (agent.status !== "running" && agent.status !== "creating") { + return this.terminalAccessFor(await this.getRequiredAgent(id)); + } + + private async terminalAccessFor( + agent: AgentRecord + ): Promise { + const id = agent.id; + const harnessLoginShell = + agent.type === "dispatch" && agent.status === "error"; + if ( + agent.status !== "running" && + agent.status !== "creating" && + !harnessLoginShell + ) { throw new AgentError("Agent is not running.", 409); } @@ -1361,6 +1594,7 @@ export class AgentManager { ); try { + if (agent.type === "dispatch") await this.harnessSupervisor?.stop(id); if (tmuxSession && (await this.runtime.hasSession(tmuxSession))) { await this.runtime.stopSession(tmuxSession, force); } @@ -1851,6 +2085,10 @@ export class AgentManager { getAgent: (id) => this.getAgent(id), getRequiredAgent: (id) => this.getRequiredAgent(id), harvestAgentTokens: (agent) => this.harvestAgentTokens(agent), + stopHarness: async (agent) => { + if (agent.type === "dispatch") + await this.harnessSupervisor?.stop(agent.id); + }, setAgentStatus: (id, status, lastError, tmuxSession) => this.setAgentStatus(id, status, lastError, tmuxSession), setArchivePhase: (id, phase) => this.setArchivePhase(id, phase), diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index 35bd95e73..b88781661 100644 --- a/apps/server/src/agents/tmux/command-builder.ts +++ b/apps/server/src/agents/tmux/command-builder.ts @@ -19,12 +19,18 @@ import { agentIdFromSessionName } from "./session-name.js"; // directly) doesn't redeclare the mapping. export const CLI_BY_AGENT_TYPE: Record< Exclude, - keyof Pick + keyof Pick< + AppConfig, + "codexBin" | "claudeBin" | "opencodeBin" | "cursorBin" | "claudeHarnessBin" + > > = { codex: "codexBin", claude: "claudeBin", opencode: "opencodeBin", cursor: "cursorBin", + // Never read: the `dispatch` branch below returns before the lookup. The + // supervisor spawns the engine; the pane is the human's shell. + dispatch: "claudeHarnessBin", }; const DISPATCH_API_URL_ENV = "DISPATCH_API_URL"; @@ -52,7 +58,19 @@ export function normalizeAgentArgsForType( if (type === "claude") { return { passthroughArgs: args, appendedSystemPrompt: null }; } + return extractAppendedSystemPrompt(args); +} +/** + * Split a `--append-system-prompt ` pair out of an arg list. This is + * how a persona launch carries its brief; the CLI branches that take the + * prompt through their own flag call this, and so does the harness persona + * builder, which folds the brief into the harness's system prompt. + */ +export function extractAppendedSystemPrompt(args: string[]): { + passthroughArgs: string[]; + appendedSystemPrompt: string | null; +} { const passthroughArgs: string[] = []; let appendedSystemPrompt: string | null = null; @@ -322,6 +340,9 @@ export function buildLaunchGuidance( ? "Report status with dispatch_event as you work and before your final response — blocked means genuinely stuck, not an error you're about to fix. Your reported status is verified against session activity and auto-corrected." : "Report status with dispatch_event. Types: working (making progress — includes debugging, fixing test failures, investigating errors), blocked (completely stuck with no further approach to try — NOT for errors or test failures you plan to fix next), waiting_user (need a decision or approval), done (task complete), idle (no-op, just answered a question). Emit working at turn start and when shifting phases. Emit a terminal event before your final response. Your reported status is verified against session activity and auto-corrected when it doesn't match." ); + rules.push( + "Once you accept a task, do not end a turn after only announcing a plan or status. Continue into substantive work in the same turn, or explicitly report waiting_user or blocked when you genuinely cannot proceed." + ); if (chatSurface) { rules.push(CHAT_SURFACE_GUIDANCE_RULE); } @@ -533,11 +554,14 @@ export function buildAgentCommand( const envPrefix = envPrefixParts.join(" "); - // Terminal agents have no CLI to launch — drop the user into an + // Terminal agents have no CLI to launch: drop the user into an // interactive login shell in the chosen cwd/worktree. `-l` alone starts a // non-interactive login shell that exits immediately under `bash -c`, // which tears down the tmux session before the browser can attach. - if (type === "terminal") { + // Harness agents get the same shell: the ACP supervisor (agents/harness) + // owns the engine process, and the pane is the human's console into the + // worktree. + if (type === "terminal" || type === "dispatch") { return `${envPrefix} "\${SHELL:-/bin/bash}" -il`; } diff --git a/apps/server/src/agents/tmux/mcp-url.ts b/apps/server/src/agents/tmux/mcp-url.ts index 760ab6b1d..f3a1d374d 100644 --- a/apps/server/src/agents/tmux/mcp-url.ts +++ b/apps/server/src/agents/tmux/mcp-url.ts @@ -6,7 +6,7 @@ import type { AppConfig } from "../../config.js"; * dedicated `/api/mcp/jobs//` route. */ export function dispatchMcpUrl( - config: AppConfig, + config: Pick, agentId: string, jobRunId?: string ): string { diff --git a/apps/server/src/agents/token-harvester.ts b/apps/server/src/agents/token-harvester.ts index 2e648e369..6e539906a 100644 --- a/apps/server/src/agents/token-harvester.ts +++ b/apps/server/src/agents/token-harvester.ts @@ -315,6 +315,8 @@ export async function harvestTokenUsage( agent: HarvestAgent, logger?: HarvestLogger ): Promise { + // Harness usage arrives on the ACP stream (agents/harness/usage-recorder.ts). + if (agent.type === "dispatch") return; if (agent.type === "codex") { await harvestCodexTokenUsage(pool, agent, logger); } else if (agent.type === "claude") { diff --git a/apps/server/src/agents/types.ts b/apps/server/src/agents/types.ts index f9810ff1f..8a8b8211c 100644 --- a/apps/server/src/agents/types.ts +++ b/apps/server/src/agents/types.ts @@ -38,6 +38,12 @@ export type AgentTerminalAccess = | { mode: "tmux"; sessionName: string } | { mode: "inert"; message: string }; +/** Where a prompt for an agent is delivered (see AgentManager.getPromptTarget). */ +export type AgentPromptTarget = + | { kind: "harness"; busy: boolean } + | { kind: "tmux"; sessionName: string } + | { kind: "inert"; message: string }; + export type AgentLatestEventInput = { type: AgentLatestEventType; message: string; diff --git a/apps/server/src/chat/envelope.ts b/apps/server/src/chat/envelope.ts index 0291108e3..5da8fb75b 100644 --- a/apps/server/src/chat/envelope.ts +++ b/apps/server/src/chat/envelope.ts @@ -65,10 +65,20 @@ export function escapeEnvelopeMarkers(text: string): string { * The whole body — text and attachment lines alike — passes through * `escapeEnvelopeMarkers`, so nothing embedded here can forge a block. */ +export type ChatEnvelopeOptions = { + /** + * The agent's replies reach the Chat tab on their own (a stream-driven + * harness), so the trailer must not send it to dispatch_chat_post for a + * plain reply, or it posts twice. + */ + nativeReplies?: boolean; +}; + export function buildChatEnvelope( messageId: string, text: string, - attachmentLines: string[] = [] + attachmentLines: string[] = [], + options: ChatEnvelopeOptions = {} ): string { const body: string[] = []; if (text.trim().length > 0) body.push(text); @@ -81,7 +91,9 @@ export function buildChatEnvelope( `--- DISPATCH CHAT (id: ${messageId}) ---`, ...(body.length > 0 ? [safeBody] : []), "--- END DISPATCH CHAT ---", - `The user only sees Chat — reply with dispatch_chat_post (replyTo: "${messageId}").`, + options.nativeReplies + ? `The user is reading Chat; your reply appears there as you write it. Only a question with options needs dispatch_chat_post (replyTo: "${messageId}").` + : `The user only sees Chat — reply with dispatch_chat_post (replyTo: "${messageId}").`, ].join("\n"); } diff --git a/apps/server/src/chat/feed-cursor.ts b/apps/server/src/chat/feed-cursor.ts new file mode 100644 index 000000000..13fcb1660 --- /dev/null +++ b/apps/server/src/chat/feed-cursor.ts @@ -0,0 +1,146 @@ +import type { ChatFeedEntry } from "@dispatch/shared"; + +import { isChatMessageId } from "./store.js"; + +export const CHAT_FEED_DEFAULT_LIMIT = 200; +export const CHAT_FEED_MAX_LIMIT = 500; + +/** + * Feed ordering is (created_at desc, source rank desc, id desc): a total + * order across the sources, so a page boundary that falls on rows with + * identical timestamps never drops or repeats a row. The cursor names the + * last entry of the previous page in that order. `at` is Postgres microsecond + * text (`to_char(..., 'YYYY-MM-DD HH24:MI:SS.US')`), not the millisecond ISO + * `at` the entries expose, so equality comparisons are exact. + */ +export type FeedCursor = { + at: string; + type: ChatFeedEntry["type"]; + id: string; +}; + +export const SOURCE_RANK: Record = { + // Turns come from agent_stream_events; the rank keeps the cursor's id + // tie-break exact against every other source. + turn: 6, + review: 5, + chat: 4, + status: 3, + pin: 2, + agent_message: 1, + media: 0, +}; + +const AT_KEY_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}$/; +export const AT_KEY_SQL = `to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US')`; + +export function encodeFeedCursor(cursor: FeedCursor): string { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +const SERIAL_ID_RE = /^\d{1,10}$/; + +function isValidCursorId(type: ChatFeedEntry["type"], id: string): boolean { + switch (type) { + case "chat": + case "agent_message": + return isChatMessageId(id); + case "status": + case "media": + case "review": + case "turn": + case "pin": + return SERIAL_ID_RE.test(id) && Number(id) <= 2_147_483_647; + } +} + +/** + * Shape-valid text like `2026-02-30 25:61:00.000000` would still reach the + * timestamp cast and fail there; round-trip through Date so only real + * instants pass (JS normalises impossible dates, so the re-rendered ISO + * string must match). + */ +function isRealTimestamp(at: string): boolean { + // JS accepts year 0000; Postgres does not (there is no year zero). + if (at.startsWith("0000-")) return false; + const iso = `${at.slice(0, 10)}T${at.slice(11, 23)}Z`; + const date = new Date(iso); + return !Number.isNaN(date.getTime()) && date.toISOString() === iso; +} + +/** + * Returns null for anything that is not a cursor this server produced — + * every field is checked against what its source column can hold, so a + * rejected cursor is a 400 at the route and never a failed cast in SQL. + */ +export function decodeFeedCursor(raw: string): FeedCursor | null { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const { at, type, id } = parsed as Record; + if (typeof at !== "string" || !AT_KEY_RE.test(at) || !isRealTimestamp(at)) { + return null; + } + if (typeof type !== "string" || !(type in SOURCE_RANK)) return null; + const sourceType = type as ChatFeedEntry["type"]; + if (typeof id !== "string" || !isValidCursorId(sourceType, id)) return null; + return { at, type: sourceType, id }; +} + +export function clampFeedLimit(limit: number | undefined): number { + if (limit === undefined || !Number.isFinite(limit)) { + return CHAT_FEED_DEFAULT_LIMIT; + } + return Math.min(CHAT_FEED_MAX_LIMIT, Math.max(1, Math.floor(limit))); +} + +export type Keyed = { + entry: E; + atKey: string; + rawId: string; + idKey: string; +}; + +/** + * "Older than the cursor" for one source. `$1` is the agent id; the clause + * appends its own parameters. Sources ranked below the cursor's include the + * cursor timestamp itself; those above it exclude it; the cursor's own + * source breaks the tie on id. `alias` qualifies the columns for a source + * whose query joins other tables that have `id`/`created_at` of their own. + */ +export function cursorClause( + type: ChatFeedEntry["type"], + idCast: "int" | "uuid", + cursor: FeedCursor | null, + params: unknown[], + alias = "" +): string { + if (!cursor) return ""; + const col = (name: string) => (alias ? `${alias}.${name}` : name); + params.push(cursor.at); + const ts = `($${params.length}::timestamp AT TIME ZONE 'UTC')`; + const rank = SOURCE_RANK[type]; + const cursorRank = SOURCE_RANK[cursor.type]; + if (rank > cursorRank) return `AND ${col("created_at")} < ${ts}`; + if (rank < cursorRank) return `AND ${col("created_at")} <= ${ts}`; + params.push(idCast === "int" ? Number(cursor.id) : cursor.id); + return `AND (${col("created_at")} < ${ts} OR (${col("created_at")} = ${ts} AND ${col("id")} < $${params.length}::${idCast}))`; +} + +export const intKey = (id: number) => String(id).padStart(20, "0"); + +/** Newest first: (atKey, source rank, id) descending. */ +export function compareNewestFirst( + a: Keyed, + b: Keyed +): number { + if (a.atKey !== b.atKey) return a.atKey < b.atKey ? 1 : -1; + const rank = SOURCE_RANK[b.entry.type] - SOURCE_RANK[a.entry.type]; + if (rank !== 0) return rank; + if (a.idKey === b.idKey) return 0; + return a.idKey < b.idKey ? 1 : -1; +} diff --git a/apps/server/src/chat/feed.ts b/apps/server/src/chat/feed.ts index 0ed6c3b86..e1bf39d02 100644 --- a/apps/server/src/chat/feed.ts +++ b/apps/server/src/chat/feed.ts @@ -12,14 +12,31 @@ import type { import { dimensionFields, parseMediaMetadata } from "../media/metadata.js"; import { - type ChatStore, - isChatMessageId, - type Queryable, - toChatMessage, -} from "./store.js"; + AT_KEY_SQL, + CHAT_FEED_DEFAULT_LIMIT, + CHAT_FEED_MAX_LIMIT, + clampFeedLimit, + compareNewestFirst, + cursorClause, + decodeFeedCursor, + encodeFeedCursor, + type FeedCursor, + intKey, + type Keyed, +} from "./feed-cursor.js"; +import { listTurnEntries, TURN_PROMPT_CHAT_ID_PATH } from "./turns.js"; +import { type ChatStore, type Queryable, toChatMessage } from "./store.js"; -export const CHAT_FEED_DEFAULT_LIMIT = 200; -export const CHAT_FEED_MAX_LIMIT = 500; +// The feed's ordering primitives live in `feed-cursor.ts` so the turn +// composer can use them without importing this module back. +export { + CHAT_FEED_DEFAULT_LIMIT, + CHAT_FEED_MAX_LIMIT, + clampFeedLimit, + decodeFeedCursor, + encodeFeedCursor, +}; +export type { FeedCursor }; export type ComposeChatFeedOptions = { /** Opaque cursor from a previous page's `nextCursor`; already decoded. */ @@ -27,133 +44,6 @@ export type ComposeChatFeedOptions = { limit?: number; }; -/** - * Feed ordering is (created_at desc, source rank desc, id desc) — a total - * order across the six tables, so a page boundary that falls on rows with - * identical timestamps never drops or repeats a row. The cursor names the - * last entry of the previous page in that order. `at` is Postgres microsecond - * text (`to_char(..., 'YYYY-MM-DD HH24:MI:SS.US')`), not the millisecond ISO - * `at` the entries expose, so equality comparisons are exact. - */ -export type FeedCursor = { - at: string; - type: ChatFeedEntry["type"]; - id: string; -}; - -const SOURCE_RANK: Record = { - review: 5, - chat: 4, - status: 3, - pin: 2, - agent_message: 1, - media: 0, -}; - -const AT_KEY_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}$/; -const AT_KEY_SQL = `to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US')`; - -export function encodeFeedCursor(cursor: FeedCursor): string { - return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); -} - -/** Serial ids: digits only, and small enough for a Postgres int4 cast. */ -const SERIAL_ID_RE = /^\d{1,10}$/; - -function isValidCursorId(type: ChatFeedEntry["type"], id: string): boolean { - switch (type) { - case "chat": - case "agent_message": - return isChatMessageId(id); - case "status": - case "media": - case "review": - case "pin": - return SERIAL_ID_RE.test(id) && Number(id) <= 2_147_483_647; - } -} - -/** - * Shape-valid text like `2026-02-30 25:61:00.000000` would still reach the - * timestamp cast and fail there; round-trip through Date so only real - * instants pass (JS normalises impossible dates, so the re-rendered ISO - * string must match). - */ -function isRealTimestamp(at: string): boolean { - // JS accepts year 0000; Postgres does not (there is no year zero). - if (at.startsWith("0000-")) return false; - const iso = `${at.slice(0, 10)}T${at.slice(11, 23)}Z`; - const date = new Date(iso); - return !Number.isNaN(date.getTime()) && date.toISOString() === iso; -} - -/** - * Returns null for anything that is not a cursor this server produced — - * every field is checked against what its source column can hold, so a - * rejected cursor is a 400 at the route and never a failed cast in SQL. - */ -export function decodeFeedCursor(raw: string): FeedCursor | null { - let parsed: unknown; - try { - parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); - } catch { - return null; - } - if (!parsed || typeof parsed !== "object") return null; - const { at, type, id } = parsed as Record; - if (typeof at !== "string" || !AT_KEY_RE.test(at) || !isRealTimestamp(at)) { - return null; - } - if (typeof type !== "string" || !(type in SOURCE_RANK)) return null; - const sourceType = type as ChatFeedEntry["type"]; - if (typeof id !== "string" || !isValidCursorId(sourceType, id)) return null; - return { at, type: sourceType, id }; -} - -export function clampFeedLimit(limit: number | undefined): number { - if (limit === undefined || !Number.isFinite(limit)) { - return CHAT_FEED_DEFAULT_LIMIT; - } - return Math.min(CHAT_FEED_MAX_LIMIT, Math.max(1, Math.floor(limit))); -} - -type Keyed = { - entry: E; - atKey: string; - /** Raw id for the cursor and the SQL tuple comparison. */ - rawId: string; - /** Fixed-width form so JS ordering matches the column's ordering. */ - idKey: string; -}; - -/** - * "Older than the cursor" for one source. `$1` is the agent id; the clause - * appends its own parameters. Sources ranked below the cursor's include the - * cursor timestamp itself; those above it exclude it; the cursor's own - * source breaks the tie on id. `alias` qualifies the columns for a source - * whose query joins other tables that have `id`/`created_at` of their own. - */ -function cursorClause( - type: ChatFeedEntry["type"], - idCast: "int" | "uuid", - cursor: FeedCursor | null, - params: unknown[], - alias = "" -): string { - if (!cursor) return ""; - const col = (name: string) => (alias ? `${alias}.${name}` : name); - params.push(cursor.at); - const ts = `($${params.length}::timestamp AT TIME ZONE 'UTC')`; - const rank = SOURCE_RANK[type]; - const cursorRank = SOURCE_RANK[cursor.type]; - if (rank > cursorRank) return `AND ${col("created_at")} < ${ts}`; - if (rank < cursorRank) return `AND ${col("created_at")} <= ${ts}`; - params.push(idCast === "int" ? Number(cursor.id) : cursor.id); - return `AND (${col("created_at")} < ${ts} OR (${col("created_at")} = ${ts} AND ${col("id")} < $${params.length}::${idCast}))`; -} - -const intKey = (id: number) => String(id).padStart(20, "0"); - /** * The message columns `toChatMessage` needs, minus `attachments` — the query * below computes that one rather than passing the stored value through, so it @@ -207,7 +97,20 @@ async function listChatEntries( `WITH page AS MATERIALIZED ( SELECT ${MESSAGE_COLUMNS_SQL}, attachments, ${AT_KEY_SQL} AS at_key FROM agent_chat_messages m - WHERE m.agent_id = $1 ${clause} + WHERE m.agent_id = $1 + -- A chat row that opened a turn is rendered by that turn entry, + -- prompt text and attachments included, so listing it again would + -- show the prompt twice. Every other chat row stays an entry of + -- its own: an agent post, a question, an answer. Checked against + -- every turn row on the agent rather than this page's, so paging + -- cannot make a prompt reappear. + AND NOT EXISTS ( + SELECT 1 + FROM agent_stream_events s + WHERE s.agent_id = $1 + AND s.kind = 'turn' + AND s.${TURN_PROMPT_CHAT_ID_PATH} = m.id::text + ) ${clause} ORDER BY m.created_at DESC, m.id DESC LIMIT $${params.length} ), expanded AS ( @@ -560,22 +463,13 @@ async function listPinEntries( })); } -/** Newest first: (atKey, source rank, id) descending. */ -function compareNewestFirst(a: Keyed, b: Keyed) { - if (a.atKey !== b.atKey) return a.atKey < b.atKey ? 1 : -1; - const rank = SOURCE_RANK[b.entry.type] - SOURCE_RANK[a.entry.type]; - if (rank !== 0) return rank; - if (a.idKey === b.idKey) return 0; - return a.idKey < b.idKey ? 1 : -1; -} - /** * Compose one agent's Chat feed at read time from chat messages, status - * events, cross-agent messages, shared media, reviews, and pin activity. - * Each source contributes its newest `limit + 1` rows past the cursor; the - * merge keeps the newest `limit` overall, so any row that belongs on the page - * is present (a row in the top `limit` overall is in the top `limit` of its - * source), and anything left over proves an older page exists. + * events, cross-agent messages, shared media, reviews, harness turns, and + * pin activity. Each source contributes its newest `limit + 1` rows past the + * cursor; the merge keeps the newest `limit` overall, so any row that belongs + * on the page is present (a row in the top `limit` overall is in the top + * `limit` of its source), and anything left over proves an older page exists. */ export async function composeChatFeed( store: ChatStore, @@ -585,16 +479,25 @@ export async function composeChatFeed( const limit = clampFeedLimit(opts.limit); const cursor = opts.cursor ?? null; const { db } = store; - const [chat, status, agentMessages, media, reviews, pins, unreadCount] = - await Promise.all([ - listChatEntries(db, agentId, cursor, limit + 1), - listStatusEntries(db, agentId, cursor, limit + 1), - listAgentMessageEntries(db, agentId, cursor, limit + 1), - listMediaEntries(db, agentId, cursor, limit + 1), - listReviewEntries(db, agentId, cursor, limit + 1), - listPinEntries(db, agentId, cursor, limit + 1), - store.countUnread(agentId), - ]); + const [ + chat, + status, + agentMessages, + media, + reviews, + turns, + pins, + unreadCount, + ] = await Promise.all([ + listChatEntries(db, agentId, cursor, limit + 1), + listStatusEntries(db, agentId, cursor, limit + 1), + listAgentMessageEntries(db, agentId, cursor, limit + 1), + listMediaEntries(db, agentId, cursor, limit + 1), + listReviewEntries(db, agentId, cursor, limit + 1), + listTurnEntries(db, agentId, cursor, limit + 1), + listPinEntries(db, agentId, cursor, limit + 1), + store.countUnread(agentId), + ]); const merged: Keyed[] = [ ...chat, @@ -602,6 +505,7 @@ export async function composeChatFeed( ...agentMessages, ...media, ...reviews, + ...turns, ...pins, ].sort(compareNewestFirst); const hasMore = merged.length > limit; diff --git a/apps/server/src/chat/service.ts b/apps/server/src/chat/service.ts index d6250caf8..32ac70356 100644 --- a/apps/server/src/chat/service.ts +++ b/apps/server/src/chat/service.ts @@ -14,6 +14,7 @@ import type { ChatUserAttachmentInput, ChatChangedEvent, ChatEntryEvent, + HarnessChangedEvent, ChatMessageEntry, ChatReadEvent, } from "@dispatch/shared"; @@ -32,6 +33,7 @@ import { formatAttachmentSize, } from "./envelope.js"; import { loadChatMessageEntry } from "./feed.js"; +import { loadLatestTurnEntry } from "./turns.js"; import { ChatStore, isChatMessageId, @@ -85,12 +87,16 @@ export type ChatDeliveryAdapter = { held: (agentId: string) => boolean; }; -type ChatAgent = Pick; +type ChatAgent = Pick; export type ChatServiceDeps = { pool: Pool; publishUiEvent: ( - event: ChatChangedEvent | ChatEntryEvent | ChatReadEvent + event: + | ChatChangedEvent + | ChatEntryEvent + | ChatReadEvent + | HarnessChangedEvent ) => void; /** Minimal agent lookup: media dir and pins are all the service needs. */ getAgent: (agentId: string) => Promise; @@ -100,6 +106,13 @@ export type ChatServiceDeps = { * resolution `GET /media/:file` serves from. */ mediaRoot: string; + /** + * Whether any browser is listening. Composing a turn entry reads the whole + * open turn, and the recorder asks for one about ten times a second, so an + * unattended agent would pay for an announcement nobody receives. Absent + * means assume someone is listening. + */ + hasUiClient?: () => boolean; /** Required for the user-side workflows (send, answer). */ delivery?: ChatDeliveryAdapter; log?: { @@ -146,6 +159,13 @@ export type ChatLaunchContextInput = { pins?: Array<{ id: string; type: string; value: string }>; /** The agent that created this one via dispatch_launch_agent, if any. */ launchedByAgentId?: string | null; + /** + * What the first turn must carry when it is more than the display text: + * an MCP launch header, a rendered template. Only the harness reads its + * first turn from the post; CLI agents get this typed into the pane + * instead. + */ + deliveryText?: string; }; /** A launch post resolved but not yet written; see `prepareLaunchContext`. */ @@ -270,10 +290,19 @@ export function validateChatContent(input: { } } +/** Whether the agent's harness streams its replies into Chat itself. */ +function nativeRepliesFor(agent: ChatAgent): boolean { + return agent.type === "dispatch"; +} + +/** One agent's in-flight turn compose, and whether another is owed after it. */ +type TurnPublish = { done: Promise; again: boolean }; + export class ChatService { readonly store: ChatStore; /** Detached pane deliveries that have not recorded their outcome yet. */ private readonly inFlightDeliveries = new Set>(); + private readonly turnPublishes = new Map(); private readonly log: NonNullable; constructor(private readonly deps: ChatServiceDeps) { @@ -316,17 +345,19 @@ export class ChatService { `attachments must have ${CHAT_ATTACHMENTS_MAX} entries or fewer.` ); } + const sessionName = await this.deliverySession( + agentId, + options.allowInert ?? false + ); + // The agent is needed either way: for its attachments, and to know + // whether its harness streams replies into Chat itself. + const agent = await this.requireAgent(agentId); let resolved: ChatAttachment[] = []; let attachmentLines: string[] = []; if (attachments.length > 0) { - const agent = await this.requireAgent(agentId); resolved = await this.resolveAttachmentsFor(agent, attachments); attachmentLines = this.describeAttachments(agent, resolved); } - const sessionName = await this.deliverySession( - agentId, - options.allowInert ?? false - ); const delivered = sessionName === null ? false : null; const row = { agentId, @@ -353,7 +384,8 @@ export class ChatService { agentId, sessionName, message, - attachmentLines + attachmentLines, + { nativeReplies: nativeRepliesFor(agent) } ); return { message, delivered: null, held }; } @@ -415,15 +447,15 @@ export class ChatService { `value must be ${CHAT_MESSAGE_MAX_CHARS} characters or fewer.` ); } + const sessionName = await this.deliverySession(agentId, true); + const agent = await this.requireAgent(agentId); let resolved: ChatAttachment[] = []; let attachmentLines: string[] = []; if (attachments.length > 0) { - const agent = await this.requireAgent(agentId); resolved = await this.resolveAttachmentsFor(agent, attachments); attachmentLines = this.describeAttachments(agent, resolved); } - const sessionName = await this.deliverySession(agentId, true); const delivered = sessionName === null ? false : null; // Reply row and answer land together or not at all: a concurrent answer @@ -475,7 +507,15 @@ export class ChatService { await this.publishEntry(agentId, answered.id); await this.publishEntry(agentId, replyMessage.id); if (sessionName !== null) { - this.deliverDetached(agentId, sessionName, replyMessage, attachmentLines); + this.deliverDetached( + agentId, + sessionName, + replyMessage, + attachmentLines, + { + nativeReplies: nativeRepliesFor(agent), + } + ); } return { question: answered, reply: replyMessage, delivered }; } @@ -639,22 +679,50 @@ export class ChatService { return this.deps.delivery; } + /** + * The first turn for a harness that takes no launch argument: the + * launch-context post, wrapped in the same envelope a typed message gets. + */ + async launchPromptFor(agentId: string): Promise { + const post = await this.store.getLaunchPost(agentId); + if (!post) return null; + const attachmentLines = post.attachments.length + ? this.describeAttachments( + await this.requireAgent(agentId), + post.attachments + ) + : []; + return buildChatEnvelope( + post.id, + post.deliveryText ?? post.text, + attachmentLines, + { nativeReplies: true } + ); + } + /** * Enqueue the envelope and return at once. The detached continuation - * records true/false on the row and publishes `chat.changed`; graceful + * records true/false on the row and publishes the delivered row; graceful * shutdown waits (briefly) for it, and a restart sweeps whatever it could - * not wait for to delivered=false. + * not wait for to delivered=false. `nativeReplies` says the agent's + * harness streams its replies into Chat itself (see nativeRepliesFor). */ private deliverDetached( agentId: string, sessionName: string, message: ChatMessage, - attachmentLines: string[] = [] + attachmentLines: string[] = [], + options: { nativeReplies?: boolean } = {} ): { held: boolean } { return this.injectDetached({ agentId, sessionName, - envelope: buildChatEnvelope(message.id, message.text, attachmentLines), + envelope: buildChatEnvelope( + message.id, + message.text, + attachmentLines, + options + ), record: async (delivered) => { await this.store.setDelivered(message.id, delivered); await this.publishEntry(agentId, message.id); @@ -771,6 +839,7 @@ export class ChatService { delivered: true, origin: "launch", launchedByAgentId: input.launchedByAgentId ?? null, + deliveryText: input.deliveryText ?? null, }); if (!message) { throw new ChatConflictError( @@ -817,6 +886,77 @@ export class ChatService { this.deps.publishUiEvent({ type: "chat.changed", agentId }); } + /** + * A Dispatch Harness stream write. The turn itself travels as a + * `chat.entry` from `publishTurnEntry`; this event carries the queue, + * and `config` also refreshes the session's model, effort, and running + * state, which a chunk does not change. + */ + publishHarnessChanged(agentId: string, config = false): void { + this.deps.publishUiEvent({ + type: "harness.changed", + agentId, + ...(config ? { config: true } : {}), + }); + } + + /** + * The agent's newest harness turn as the feed row it now is, so a mounted + * feed replaces that one row instead of refetching every page it holds. + * The newest turn is always the affected one: the recorder only ever + * writes into the turn it opened last. A flush that changed nothing about + * any turn (a queue edit) still publishes, and the client's upsert is a + * no-op when the row is unchanged. + * + * Never rejects: a stream write must not fail because its announcement did. + * + * One compose per agent at a time, with a single trailing re-run. The + * recorder flushes about ten times a second and each compose reads the + * whole open turn, so two of them overlap on a slow disk and the older + * read can publish last: a shorter, possibly still-streaming entry lands + * over a newer one. Nothing would correct it, because the turn's own + * `harness.changed` no longer refetches the feed and the flush that + * settled it was the last. Requests arriving mid-compose collapse into + * one re-run, since only the newest state is worth sending. + */ + async publishTurnEntry(agentId: string): Promise { + // A reconnecting client refetches the whole feed from the rows, so + // nothing is lost by not composing while nobody is watching. + if (this.deps.hasUiClient && !this.deps.hasUiClient()) return; + const running = this.turnPublishes.get(agentId); + if (running) { + running.again = true; + return running.done; + } + const state: TurnPublish = { done: Promise.resolve(), again: false }; + this.turnPublishes.set(agentId, state); + state.done = (async () => { + try { + do { + state.again = false; + await this.composeTurnEntry(agentId); + } while (state.again); + } finally { + this.turnPublishes.delete(agentId); + } + })(); + return state.done; + } + + private async composeTurnEntry(agentId: string): Promise { + try { + const entry = await loadLatestTurnEntry(this.store.db, agentId); + if (entry) { + this.deps.publishUiEvent({ type: "chat.entry", agentId, entry }); + } + } catch (error) { + this.log.warn( + { err: error, agentId }, + "chat: could not compose the harness turn for its feed event" + ); + } + } + /** A mark-read landed: the count, and which rows it stamped. */ publishRead( agentId: string, @@ -866,6 +1006,43 @@ export class ChatService { return agentIds; } + /** + * A Dispatch Harness agent brought back after a restart: the messages + * that were waiting in its queue when the service went down are still + * `delivered: null`, and go out again in the order they were sent. + */ + async redeliverPending(agentId: string): Promise { + const pending = await this.store.listPendingDeliveries(agentId); + if (pending.length === 0) return 0; + const agent = await this.requireAgent(agentId); + // A harness that came back has a pane; without one (an inert runtime) + // there is nowhere to redeliver to. The boot sweep skips running + // harness agents on purpose, so the rows are abandoned here, or they + // would read as pending for as long as the agent runs. + const sessionName = await this.deliverySession(agentId, true); + if (sessionName === null) { + await this.abandonPending([agentId]); + return 0; + } + for (const message of pending) { + const lines = message.attachments.length + ? this.describeAttachments(agent, message.attachments) + : []; + this.deliverDetached(agentId, sessionName, message, lines, { + nativeReplies: nativeRepliesFor(agent), + }); + } + this.publishChanged(agentId); + return pending.length; + } + + /** The boot sweep for agents that did not come back. */ + async abandonPending(agentIds: string[]): Promise { + const touched = await this.store.sweepPendingDeliveriesFor(agentIds); + for (const agentId of touched) this.publishChanged(agentId); + return touched; + } + /** * Register a detached delivery's settlement chain so graceful shutdown can * wait for it. The promise must never reject (the route already handles diff --git a/apps/server/src/chat/store.ts b/apps/server/src/chat/store.ts index bbbc8b2dd..801f81032 100644 --- a/apps/server/src/chat/store.ts +++ b/apps/server/src/chat/store.ts @@ -40,6 +40,8 @@ export type InsertChatMessageInput = { origin?: ChatMessageOrigin | null; /** Launch-context posts only: the agent that created this one. */ launchedByAgentId?: string | null; + /** Launch posts: text delivered to a harness when it differs from the post. */ + deliveryText?: string | null; }; export type UpdateChatMessageInput = { @@ -103,12 +105,16 @@ type Row = { read_at: Date | null; origin: ChatMessageOrigin | null; launched_by_agent_id: string | null; + delivery_text?: string | null; /** Only on rows read through the feed query; see `listChatEntries`. */ reactions?: ReactionJson[] | null; created_at: Date; updated_at: Date; }; +/** A launch post plus the text its first turn delivers (harness agents). */ +export type LaunchPost = ChatMessage & { deliveryText: string | null }; + export function toChatMessage(row: Row): ChatMessage { return { id: row.id, @@ -155,8 +161,8 @@ export class ChatStore { const result = await this.db.query( `INSERT INTO agent_chat_messages (id, agent_id, author_kind, kind, text, reply_to, question, - attachments, delivered, origin, launched_by_agent_id) - VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, $10, $11) + attachments, delivered, origin, launched_by_agent_id, delivery_text) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, $10, $11, $12) RETURNING *`, [ input.id ?? randomUUID(), @@ -170,6 +176,7 @@ export class ChatStore { input.delivered ?? null, input.origin ?? null, input.launchedByAgentId ?? null, + input.deliveryText ?? null, ] ); return toChatMessage(result.rows[0]); @@ -188,8 +195,8 @@ export class ChatStore { const result = await this.db.query( `INSERT INTO agent_chat_messages (id, agent_id, author_kind, kind, text, reply_to, question, - attachments, delivered, origin, launched_by_agent_id) - VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, $10, $11) + attachments, delivered, origin, launched_by_agent_id, delivery_text) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, $10, $11, $12) ON CONFLICT (id) DO NOTHING RETURNING *`, [ @@ -204,6 +211,7 @@ export class ChatStore { input.delivered ?? null, input.origin ?? null, input.launchedByAgentId ?? null, + input.deliveryText ?? null, ] ); const row = result.rows[0]; @@ -267,11 +275,28 @@ export class ChatStore { const result = await this.db.query<{ agent_id: string }>( `UPDATE agent_chat_messages SET delivered = false WHERE author_kind = 'user' AND delivered IS NULL + AND agent_id NOT IN ( + SELECT id FROM agents + WHERE type = 'dispatch' AND status = 'running' AND deleted_at IS NULL + ) RETURNING agent_id` ); return [...new Set(result.rows.map((row) => row.agent_id))]; } + /** The same sweep for named agents only (a harness that did not come back). */ + async sweepPendingDeliveriesFor(agentIds: string[]): Promise { + if (agentIds.length === 0) return []; + const result = await this.db.query<{ agent_id: string }>( + `UPDATE agent_chat_messages SET delivered = false + WHERE author_kind = 'user' AND delivered IS NULL + AND agent_id = ANY($1::text[]) + RETURNING agent_id`, + [agentIds] + ); + return [...new Set(result.rows.map((row) => row.agent_id))]; + } + /** * User reactions still pending from a previous process, marked * not-delivered for the same reason as `sweepPendingDeliveries`. Returns @@ -286,6 +311,32 @@ export class ChatStore { return [...new Set(result.rows.map((row) => row.agent_id))]; } + /** User messages still waiting to be delivered, oldest first. */ + async listPendingDeliveries(agentId: string): Promise { + const result = await this.db.query( + `SELECT * FROM agent_chat_messages + WHERE agent_id = $1 AND author_kind = 'user' AND delivered IS NULL + ORDER BY created_at ASC`, + [agentId] + ); + return result.rows.map(toChatMessage); + } + + /** The launch-context post recorded when the agent was created, if any. */ + async getLaunchPost(agentId: string): Promise { + const result = await this.db.query( + `SELECT * FROM agent_chat_messages + WHERE agent_id = $1 AND origin = 'launch' + ORDER BY created_at ASC + LIMIT 1`, + [agentId] + ); + const row = result.rows[0]; + return row + ? { ...toChatMessage(row), deliveryText: row.delivery_text ?? null } + : null; + } + /** A message's reactions, oldest first — the order the feed lists them in. */ async listReactions(messageId: string): Promise { if (!isChatMessageId(messageId)) return []; @@ -382,11 +433,6 @@ export class ChatStore { return result.rows[0] ? toChatMessage(result.rows[0]) : null; } - /** - * Mark unread agent messages as read. With `upTo`, only messages created - * at or before that message (an unknown id marks nothing). Returns the - * number of rows updated. - */ /** * Mark unread agent messages read — up to and including `upTo`, or all of * them. Reports what was marked so a client can mirror it on the rows it @@ -429,10 +475,24 @@ export class ChatStore { }; } + async markFeedRead(agentId: string): Promise { + await this.db.query( + `UPDATE agents SET chat_read_at = NOW() WHERE id = $1`, + [agentId] + ); + } + async countUnread(agentId: string): Promise { const result = await this.db.query<{ count: string }>( - `SELECT COUNT(*)::text AS count FROM agent_chat_messages - WHERE agent_id = $1 AND author_kind = 'agent' AND read_at IS NULL`, + `SELECT ( + (SELECT COUNT(*) FROM agent_chat_messages + WHERE agent_id = $1 AND author_kind = 'agent' AND read_at IS NULL) + + (SELECT COUNT(*) FROM agent_stream_events s + JOIN agents a ON a.id = s.agent_id + WHERE s.agent_id = $1 AND s.kind = 'turn' + AND s.payload->>'state' = 'settled' + AND s.updated_at > COALESCE(a.chat_read_at, '-infinity')) + )::text AS count`, [agentId] ); return Number(result.rows[0].count); @@ -448,14 +508,28 @@ export class ChatStore { unread: string; pending: string; }>( - `SELECT m.agent_id, - COUNT(*) FILTER (WHERE m.read_at IS NULL)::text AS unread, - COUNT(*) FILTER (WHERE m.kind = 'question' AND m.answer IS NULL)::text AS pending - FROM agent_chat_messages m - JOIN agents a ON a.id = m.agent_id AND a.deleted_at IS NULL - WHERE m.author_kind = 'agent' - AND (m.read_at IS NULL OR (m.kind = 'question' AND m.answer IS NULL)) - GROUP BY m.agent_id` + `SELECT agent_id, + SUM(unread)::text AS unread, + SUM(pending)::text AS pending + FROM ( + SELECT m.agent_id, + COUNT(*) FILTER (WHERE m.read_at IS NULL) AS unread, + COUNT(*) FILTER (WHERE m.kind = 'question' AND m.answer IS NULL) AS pending + FROM agent_chat_messages m + JOIN agents a ON a.id = m.agent_id AND a.deleted_at IS NULL + WHERE m.author_kind = 'agent' + AND (m.read_at IS NULL OR (m.kind = 'question' AND m.answer IS NULL)) + GROUP BY m.agent_id + UNION ALL + SELECT s.agent_id, COUNT(*) AS unread, 0 AS pending + FROM agent_stream_events s + JOIN agents a ON a.id = s.agent_id AND a.deleted_at IS NULL + WHERE s.kind = 'turn' + AND s.payload->>'state' = 'settled' + AND s.updated_at > COALESCE(a.chat_read_at, '-infinity') + GROUP BY s.agent_id + ) counts + GROUP BY agent_id` ); const agents: ChatUnreadSummary["agents"] = {}; for (const row of result.rows) { diff --git a/apps/server/src/chat/turns.ts b/apps/server/src/chat/turns.ts new file mode 100644 index 000000000..1e0322de2 --- /dev/null +++ b/apps/server/src/chat/turns.ts @@ -0,0 +1,684 @@ +import type { + ChatMessage, + ChatQuestionOption, + ChatTurnEntry, + ChatTurnPlanEntry, + ChatTurnQuestionRef, + ChatTurnStep, + HarnessPrompt, + HarnessQueuedPrompt, +} from "@dispatch/shared"; + +import { + AT_KEY_SQL, + cursorClause, + type FeedCursor, + intKey, + type Keyed, +} from "./feed-cursor.js"; + +import { INTERRUPTED_BY_RESTART } from "../agents/harness/stream-recorder.js"; +import type { PromptSource } from "../agents/harness/prompt-source.js"; +import type { + AssistantPayload, + PlanPayload, + StreamEventRow, + ThoughtPayload, + ToolPayload, + TurnPayload, +} from "../agents/harness/stream-store.js"; +import { isChatMessageId, type Queryable, toChatMessage } from "./store.js"; + +export type TurnSourceRow = Pick< + StreamEventRow, + "id" | "seq" | "kind" | "key" | "payload" | "createdAt" | "updatedAt" +>; + +/** + * A question the agent asked mid-turn, as the assembler carries it. Server + * side only: the wire sends the card as a `chat` entry and gives the turn a + * `ChatTurnQuestionRef`, whose answered flag is derived from this. + */ +type AssembledQuestion = { + /** The chat message id; answers post against it. */ + id: string; + text: string; + options: ChatQuestionOption[]; + allowFreeform: boolean; + answer: { value: string; label?: string } | null; + createdAt: string; +}; + +/** + * A turn as the assembler shapes it, on the way to the feed entry + * `toTurnEntry` frames from it. Not a wire type: `toTurnEntry` is its only + * reader, and it carries each question whole because the entry needs the + * answer state off it. + */ +export type AssembledTurn = { + id: string; + prompt: HarnessPrompt; + trace: { + startedAt: string; + endedAt?: string; + /** `interrupted`: the turn was cancelled (Stop, Ctrl+C, Send now). */ + finalResult?: "ok" | "error" | "interrupted"; + steps: ChatTurnStep[]; + }; + result: { text: string; streaming: boolean; truncated?: boolean } | null; + error?: string; + /** Questions the agent asked during this turn, oldest first. */ + questions?: AssembledQuestion[]; + /** + * What the turn did, in the agent's own words: the message of the last + * dispatch_event it sent during the turn ("Answered README question"). + * Absent when the agent sent none. + */ + label?: string; + /** The task list as the engine last published it during this turn. */ + plan?: ChatTurnPlanEntry[]; + /** Context used and, where the engine reports it, cost so far in this session. */ + usage?: { used: number; size: number; costUsd: number | null }; +}; + +/** The agent's own status reports already show as status lines; in a trace they are noise. */ +const DROPPED_TOOL_TITLES = new Set(["mcp__dispatch__dispatch_event"]); + +function firstLine(text: string): string { + const line = text.split("\n").find((l) => l.trim().length > 0) ?? ""; + return line.length > 120 ? `${line.slice(0, 117)}…` : line; +} + +function promptFor( + source: PromptSource, + chat: Map +): HarnessPrompt { + if (source.source === "chat") { + const message = chat.get(source.chatMessageId); + return { + source: message?.origin === "launch" ? "launch" : "chat", + text: message?.text ?? "", + chatMessageId: source.chatMessageId, + attachments: message?.attachments ?? [], + }; + } + if (source.source === "agent") { + return { + source: "agent", + text: source.text, + senderName: source.senderName, + senderAgentId: source.senderId, + attachments: [], + }; + } + return { source: "system", text: source.text, attachments: [] }; +} + +/** The engine's read tool wraps its result as …. */ +const READ_PATH_TAG = /^([^<]+)<\/path>/; + +/** + * The engine sends no ACP `locations`; the paths live in the tool's raw + * input (file_path, path, pattern) or, for read, in the output wrapper. + */ +export function locationsFromInput( + input: unknown, + terminalOutput: string | null | undefined +): { path: string; line?: number }[] { + const obj = + typeof input === "object" && input !== null && !Array.isArray(input) + ? (input as Record) + : null; + const path = obj + ? [obj.file_path, obj.path, obj.filePath, obj.file].find( + (v): v is string => typeof v === "string" && v.length > 0 + ) + : undefined; + if (path) { + const line = + typeof obj?.offset === "number" && obj.offset > 1 + ? obj.offset + : typeof obj?.line === "number" + ? obj.line + : undefined; + return [line !== undefined ? { path, line } : { path }]; + } + const tagged = terminalOutput ? READ_PATH_TAG.exec(terminalOutput) : null; + return tagged ? [{ path: tagged[1] }] : []; +} + +const LABEL_MAX = 80; + +/** A dispatch_event call's type and message, when the row is one. */ +function statusEventOf( + row: TurnSourceRow +): { type: string; message: string } | null { + const p = row.payload as Partial; + if (p.title !== "mcp__dispatch__dispatch_event") return null; + const input = p.input; + if (typeof input !== "object" || input === null) return null; + const { type, message } = input as { type?: unknown; message?: unknown }; + if (typeof type !== "string" || typeof message !== "string") return null; + const trimmed = message.replace(/\s+/g, " ").trim(); + if (!trimmed) return null; + return { + type, + message: + trimmed.length > LABEL_MAX + ? `${trimmed.slice(0, LABEL_MAX - 1)}…` + : trimmed, + }; +} + +function toolStep(row: TurnSourceRow): ChatTurnStep | null { + const p = row.payload as Partial; + const title = p.title ?? ""; + if (DROPPED_TOOL_TITLES.has(title)) return null; + const settled = p.status === "completed" || p.status === "failed"; + return { + id: `stream:${row.id}`, + kind: p.toolKind ?? "other", + label: title, + status: + p.status === "completed" + ? "ok" + : p.status === "failed" + ? "error" + : "running", + startedAt: row.createdAt.toISOString(), + ...(settled + ? { + endedAt: row.updatedAt.toISOString(), + durMs: Math.max(0, row.updatedAt.getTime() - row.createdAt.getTime()), + } + : {}), + detail: { + toolKind: p.toolKind, + locations: p.locations?.length + ? p.locations + : locationsFromInput(p.input, p.terminalOutput), + diff: p.diff ?? null, + terminalOutput: p.terminalOutput ?? null, + ...(p.truncated ? { truncated: true } : {}), + ...(p.input !== undefined ? { input: p.input } : {}), + ...(p.parentToolCallId ? { parentToolCallId: p.parentToolCallId } : {}), + }, + }; +} + +function noteStep( + row: TurnSourceRow, + kind: "note" | "think", + running = false +): ChatTurnStep { + const p = row.payload as Partial; + const text = p.text ?? ""; + return { + id: `stream:${row.id}`, + kind, + label: kind === "think" ? "thinking" : firstLine(text), + status: running ? "running" : "ok", + startedAt: row.createdAt.toISOString(), + ...(running + ? {} + : { + endedAt: row.updatedAt.toISOString(), + // Thought rows grow with each chunk, so their span is the time + // the model spent thinking; the rail shows it like any step. + durMs: Math.max(0, row.updatedAt.getTime() - row.createdAt.getTime()), + }), + detail: { text, ...(p.truncated ? { truncated: true } : {}) }, + }; +} + +/** One turn's rows: its `turn` row (null for a pre-turn group) and the rest. */ +export type TurnGroup = { turn: TurnSourceRow | null; rows: TurnSourceRow[] }; + +/** + * Cut ascending stream rows into turns at each `turn` row. Rows before the + * first one form a single leading group with no turn row of its own: that + * is history from before turn rows existed, and it assembles into one + * closed synthetic turn. Callers rely on the result indexing one to one + * with {@link assembleTurns} over the same rows. + */ +export function groupTurnRows(rows: TurnSourceRow[]): TurnGroup[] { + const groups: TurnGroup[] = []; + let current: TurnGroup | null = null; + for (const row of rows) { + if (row.kind === "turn") { + current = { turn: row, rows: [] }; + groups.push(current); + continue; + } + if (!current) { + current = { turn: null, rows: [] }; + groups.push(current); + } + current.rows.push(row); + } + return groups; +} + +/** An agent question as the view carries it. */ +function toQuestion(message: ChatMessage): AssembledQuestion { + return { + id: message.id, + text: message.text, + options: message.question?.options ?? [], + allowFreeform: message.question?.allowFreeform === true, + answer: message.answer + ? { + value: message.answer.value, + ...(message.answer.label ? { label: message.answer.label } : {}), + } + : null, + createdAt: message.createdAt, + }; +} + +/** + * Hang each step that names a parent under that parent, in stream order. + * A parent outside the turn (or dropped as a status event) leaves the child + * at the top level rather than losing it. + */ +function nestSteps( + flat: { step: ChatTurnStep; key: string | null; parent: string | null }[] +): ChatTurnStep[] { + const byKey = new Map(); + for (const { step, key } of flat) if (key) byKey.set(key, step); + const top: ChatTurnStep[] = []; + for (const { step, parent } of flat) { + const owner = parent ? byKey.get(parent) : undefined; + if (owner && owner !== step) (owner.children ??= []).push(step); + else top.push(step); + } + return top; +} + +function planEntriesOf(row: TurnSourceRow): ChatTurnPlanEntry[] { + const p = row.payload as Partial; + return (p.entries ?? []).map((e) => ({ + content: e.content, + status: e.status as ChatTurnPlanEntry["status"], + priority: e.priority as ChatTurnPlanEntry["priority"], + })); +} + +/** Cut ascending stream rows into turns and shape each for the view. */ +export function assembleTurns( + rows: TurnSourceRow[], + chat: Map, + questions: ChatMessage[] = [] +): AssembledTurn[] { + const groups = groupTurnRows(rows); + // Each question belongs to the latest turn that had started when it was + // posted; one posted before any turn goes with the first. + const starts = groups.map((g) => (g.turn ?? g.rows[0]).createdAt.getTime()); + const byGroup = new Map(); + for (const message of [...questions].sort( + (a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) + )) { + const at = Date.parse(message.createdAt); + let index = 0; + for (let i = 0; i < starts.length; i += 1) { + if (starts[i] <= at) index = i; + } + const list = byGroup.get(index) ?? []; + list.push(toQuestion(message)); + byGroup.set(index, list); + } + return groups.map((group, index) => { + const turnPayload = group.turn ? (group.turn.payload as TurnPayload) : null; + const anchor = group.turn ?? group.rows[0]; + const startedAt = anchor.createdAt.toISOString(); + const turnQuestions = byGroup.get(index); + const settled = turnPayload?.state === "settled"; + let result: AssembledTurn["result"] = null; + const assistants = group.rows.filter((r) => r.kind === "assistant"); + const last = assistants[assistants.length - 1]; + // In a turn still running, a thought that is the newest row is the one + // being written now: it reads as a running step, not a finished one. + const live = group.turn !== null && !settled; + const newest = group.rows[group.rows.length - 1]; + // The agent's own account of the turn: dispatch_event messages are + // dropped as steps but the last one names what happened. A terminal + // event (done, idle, …) wins over the last "working". + const flat: { + step: ChatTurnStep; + key: string | null; + parent: string | null; + }[] = []; + let plan: ChatTurnPlanEntry[] | undefined; + let label: string | undefined; + let labelTerminal = false; + for (const row of group.rows) { + if (row.kind === "tool_call") { + const status = statusEventOf(row); + if (status) { + const terminal = status.type !== "working"; + if (terminal || !labelTerminal) { + label = status.message; + labelTerminal = terminal; + } + } + const step = toolStep(row); + if (step) { + flat.push({ + step, + key: row.key, + parent: + (row.payload as Partial).parentToolCallId ?? null, + }); + } + } else if (row.kind === "thought") { + flat.push({ + step: noteStep(row, "think", live && row === newest), + key: null, + parent: null, + }); + } else if (row.kind === "assistant") { + if (row === last) { + const p = row.payload as Partial; + result = { + text: p.text ?? "", + streaming: p.streaming === true && !settled, + ...(p.truncated ? { truncated: true } : {}), + }; + } else { + flat.push({ step: noteStep(row, "note"), key: null, parent: null }); + } + } else if (row.kind === "plan") { + plan = planEntriesOf(row); + } + } + const steps = nestSteps(flat); + const error = turnPayload?.error; + const lastRow = group.rows[group.rows.length - 1]; + const trace: AssembledTurn["trace"] = { startedAt, steps }; + if (settled) { + if (turnPayload?.endedAt) trace.endedAt = turnPayload.endedAt; + trace.finalResult = error + ? "error" + : turnPayload?.stopReason === "cancelled" + ? "interrupted" + : "ok"; + } else if (!group.turn && lastRow) { + // Rows from before turn rows existed: one closed synthetic turn. + trace.endedAt = lastRow.updatedAt.toISOString(); + trace.finalResult = "ok"; + } + const usage = turnPayload?.usage + ? { + used: turnPayload.usage.used, + size: turnPayload.usage.size, + costUsd: + turnPayload.usage.cost && turnPayload.usage.cost.currency === "USD" + ? turnPayload.usage.cost.amount + : null, + } + : undefined; + return { + // A pre-turn group is named by its first row, not by its position, so + // a feed cursor over it compares against a real row id. + id: group.turn ? `turn:${group.turn.id}` : `turn:pre:${group.rows[0].id}`, + prompt: turnPayload + ? promptFor(turnPayload.prompt, chat) + : { source: "system", text: "Earlier activity", attachments: [] }, + trace, + result, + ...(turnQuestions ? { questions: turnQuestions } : {}), + ...(label ? { label } : {}), + ...(error ? { error } : {}), + ...(plan ? { plan } : {}), + ...(usage ? { usage } : {}), + }; + }); +} + +/** + * One assembled turn as the feed row it is. The anchor's `created_at` fixes + * the entry's place for the turn's life; `updatedAt` moves with the newest + * row folded into it, which is what makes a streaming turn follow the + * scroll. Questions become references: their cards are `chat` entries of + * their own, in time order, so the turn only says which ones it asked. + */ +export function toTurnEntry( + turn: AssembledTurn, + group: TurnGroup, + agentId: string +): ChatTurnEntry { + const anchor = group.turn ?? group.rows[0]; + const payload = group.turn ? (group.turn.payload as TurnPayload) : null; + // A group with no turn row is closed by definition: it is history from + // before turn rows existed. Otherwise the row itself says so. + const settled = payload === null || payload.state === "settled"; + let updatedAt = anchor.updatedAt; + for (const row of group.rows) { + if (row.updatedAt > updatedAt) updatedAt = row.updatedAt; + } + // A turn the service went down under settles carrying the restart marker + // as its error. That is a cut, not a failure the engine reported, so the + // entry says `interrupted` and drops the marker rather than showing it as + // an error line under the result. + const byRestart = payload?.error === INTERRUPTED_BY_RESTART; + const trace: ChatTurnEntry["trace"] = byRestart + ? { ...turn.trace, finalResult: "interrupted" } + : turn.trace; + const error = byRestart ? undefined : turn.error; + const questions: ChatTurnQuestionRef[] | undefined = turn.questions?.map( + (q) => ({ messageId: q.id, answered: q.answer !== null }) + ); + return { + type: "turn", + id: turn.id, + agentId, + at: anchor.createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + prompt: turn.prompt, + trace, + result: turn.result, + settled, + interrupted: trace.finalResult === "interrupted", + ...(error ? { error } : {}), + ...(turn.label ? { label: turn.label } : {}), + ...(turn.plan ? { plan: turn.plan } : {}), + ...(turn.usage ? { usage: turn.usage } : {}), + ...(questions ? { questions } : {}), + }; +} + +type StreamRowResult = { + id: number | string; + seq: number; + kind: StreamEventRow["kind"]; + key: string | null; + payload: Record; + created_at: Date; + updated_at: Date; + at_key: string; +}; + +/** + * One page of turn entries, newest first, past `cursor`. + * + * The anchors are the `turn` rows, plus the agent's oldest stream row when + * that row is not itself a turn row: the rows recorded before turn rows + * existed assemble into one closed synthetic turn, and it needs an anchor + * of its own to sort and page by. The page's rows are everything from the + * oldest selected anchor up to, but not including, the first turn row above + * the page, so paging older never re-reads a newer turn and a turn belongs + * wholly to the page its anchor falls on. + */ +export async function listTurnEntries( + db: Queryable, + agentId: string, + cursor: FeedCursor | null, + limit: number +): Promise[]> { + const params: unknown[] = [agentId]; + const clause = cursorClause("turn", "int", cursor, params); + params.push(limit); + const anchors = await db.query<{ id: number | string; seq: number }>( + `SELECT id, seq + FROM agent_stream_events + WHERE agent_id = $1 + AND ( + kind = 'turn' + OR seq = ( + SELECT min(seq) FROM agent_stream_events WHERE agent_id = $1 + ) + ) ${clause} + ORDER BY created_at DESC, id DESC + LIMIT $${params.length}`, + params + ); + if (anchors.rows.length === 0) return []; + const seqs = anchors.rows.map((r) => r.seq); + const fromSeq = Math.min(...seqs); + const maxSeq = Math.max(...seqs); + const above = await db.query<{ seq: number; created_at: Date }>( + `SELECT seq, created_at + FROM agent_stream_events + WHERE agent_id = $1 AND kind = 'turn' AND seq > $2 + ORDER BY seq ASC + LIMIT 1`, + [agentId, maxSeq] + ); + const untilSeq = above.rows[0]?.seq ?? null; + const untilAt = above.rows[0]?.created_at ?? null; + const rows = await db.query( + `SELECT id, seq, kind, key, payload, created_at, updated_at, + ${AT_KEY_SQL} AS at_key + FROM agent_stream_events + WHERE agent_id = $1 AND seq >= $2 + AND ($3::int IS NULL OR seq < $3) + ORDER BY seq ASC`, + [agentId, fromSeq, untilSeq] + ); + const source: TurnSourceRow[] = []; + // The cursor needs the anchor row's microsecond time, which only Postgres + // can render exactly; the ISO form the entry exposes is milliseconds. + const atKeyById = new Map(); + for (const r of rows.rows) { + const id = Number(r.id); + atKeyById.set(id, r.at_key); + source.push({ + id, + seq: r.seq, + kind: r.kind, + key: r.key, + payload: r.payload, + createdAt: r.created_at, + updatedAt: r.updated_at, + }); + } + const chat = await loadChatMessages(db, chatPromptIds(source)); + // Questions the agent asked while this page's turns ran. Bounded above as + // well as below: a question from a newer turn would otherwise attach to + // this page's last turn, which is the one that had started when it landed. + const since = source.length ? source[0].createdAt : new Date(0); + const asked = await db.query( + `SELECT * FROM agent_chat_messages + WHERE agent_id = $1 AND author_kind = 'agent' AND kind = 'question' + AND created_at >= $2 + AND ($3::timestamptz IS NULL OR created_at < $3) + ORDER BY created_at ASC`, + [agentId, since, untilAt] + ); + const questions = asked.rows.map((row) => toChatMessage(row as never)); + const groups = groupTurnRows(source); + const turns = assembleTurns(source, chat, questions); + const keyed: Keyed[] = []; + turns.forEach((turn, index) => { + const group = groups[index]; + if (!group) return; + const anchor = group.turn ?? group.rows[0]; + const atKey = atKeyById.get(anchor.id); + if (atKey === undefined) return; + keyed.push({ + entry: toTurnEntry(turn, group, agentId), + atKey, + rawId: String(anchor.id), + idKey: intKey(anchor.id), + }); + }); + // Assembly runs oldest first; the feed merges newest first. + return keyed.reverse(); +} + +/** + * The turn payload's prompt-id path, for the one reader that cannot go + * through `TurnPayload`: the feed's anti-join, which has to ask this in SQL. + * It lives beside `chatPromptIds` because they are the same fact in two + * languages and only one of them is compiler-checked; a rename of + * `TurnPayload.prompt` or `chatMessageId` has to change both. + */ +export const TURN_PROMPT_CHAT_ID_PATH = "payload->'prompt'->>'chatMessageId'"; + +/** The chat message ids the page's turn rows name as their prompt. */ +function chatPromptIds(rows: TurnSourceRow[]): string[] { + return rows + .filter((r) => r.kind === "turn") + .map((r) => (r.payload as TurnPayload).prompt) + .filter( + (p): p is Extract => p.source === "chat" + ) + .map((p) => p.chatMessageId); +} + +/** + * The agent's newest turn as one feed entry, for the row-level event the + * recorder's flush publishes. Null when the agent has no stream rows. + */ +export async function loadLatestTurnEntry( + db: Queryable, + agentId: string +): Promise { + const [newest] = await listTurnEntries(db, agentId, null, 1); + return newest?.entry ?? null; +} + +/** The chat messages behind chat-sourced prompts, by id. */ +async function loadChatMessages( + db: Queryable, + ids: string[] +): Promise> { + const chat = new Map(); + // The cast below is the only thing standing between a stored prompt and a + // permanent 500 on this agent's turns, so ids Postgres would reject are + // dropped here rather than sent. A dropped id reads as a prompt with no + // chat text behind it. + const valid = ids.filter((id) => isChatMessageId(id)); + if (valid.length === 0) return chat; + const messages = await db.query( + `SELECT * FROM agent_chat_messages WHERE id = ANY($1::uuid[])`, + [valid] + ); + for (const row of messages.rows) { + const message = toChatMessage(row as never); + chat.set(message.id, message); + } + return chat; +} + +/** The supervisor's queue, shaped for the view with chat text joined. */ +export async function loadQueued( + db: Queryable, + queued: { id: string; source: PromptSource; createdAt: string }[] +): Promise { + const chat = await loadChatMessages( + db, + queued + .map((q) => q.source) + .filter( + (p): p is Extract => + p.source === "chat" + ) + .map((p) => p.chatMessageId) + ); + return queued.map((q) => ({ + ...promptFor(q.source, chat), + id: q.id, + createdAt: q.createdAt, + })); +} diff --git a/apps/server/src/chat/user-prompt.ts b/apps/server/src/chat/user-prompt.ts index 23e8dff50..8168127bd 100644 --- a/apps/server/src/chat/user-prompt.ts +++ b/apps/server/src/chat/user-prompt.ts @@ -37,8 +37,10 @@ export type UserPromptRouting = { export function routesUserPromptThroughChat( routing: UserPromptRouting ): boolean { - if (!routing.chatSurfaceEnabled) return false; if (!routing.submit) return false; + // A harness agent has no pane to type into: Chat is its only input, flag or not. + if (routing.agentType === "dispatch") return true; + if (!routing.chatSurfaceEnabled) return false; return routing.agentType !== "terminal"; } @@ -64,13 +66,12 @@ export async function deliverUserPrompt( text: string, submit: boolean ): Promise { - if (!(await deps.isChatSurfaceEnabled())) return false; // A missing agent keeps the pane path so the route's own 404 stands. const agent = await deps.getAgent(agentId).catch(() => null); if (!agent) return false; if ( !routesUserPromptThroughChat({ - chatSurfaceEnabled: true, + chatSurfaceEnabled: await deps.isChatSurfaceEnabled(), agentType: agent.type, submit, }) diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 5a81ea830..cc21da131 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -4,7 +4,10 @@ import { readFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { resolveConfiguredPath } from "./shared/lib/resolve-tilde.js"; +import { + resolveConfiguredPath, + resolveTilde, +} from "./shared/lib/resolve-tilde.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -24,6 +27,12 @@ export type AppConfig = { claudeBin: string; opencodeBin: string; cursorBin: string; + /** The Claude engine's ACP adapter (`claude-agent-acp`). */ + claudeHarnessBin: string; + /** The Codex engine's ACP adapter (`codex-acp`). */ + codexHarnessBin: string; + /** The Gemini CLI, which speaks ACP itself. */ + geminiBin: string; agentRuntime: "tmux" | "inert"; sessionPrefix: string; tls: TlsConfig | null; @@ -74,6 +83,22 @@ function resolveAgentRuntime(): "tmux" | "inert" { } } +/** + * An executable read from configuration. A bare command name is left exactly + * as written, because `resolveExecutable` looks those up on PATH; anything + * naming a path gets its leading `~` expanded, the way every other + * configured path does. + * + * Neither loader of `.env` expands `~` for us: the service unit uses + * `EnvironmentFile` and the server uses dotenv, so a literal `~` survives to + * `path.resolve`, which turns the documented `~/.local/bin/...` into a + * subdirectory of the install directory and fails every engine start. Only + * `bin/dispatch-dev` was hiding this, because it sources `.env` through bash. + */ +function resolveConfiguredBin(value: string): string { + return value.includes("/") ? resolveTilde(value) : value; +} + export function loadConfig(): AppConfig { const config: AppConfig = { host: process.env.DISPATCH_HOST ?? process.env.HOST ?? "127.0.0.1", @@ -84,16 +109,29 @@ export function loadConfig(): AppConfig { process.env.MEDIA_ROOT ?? path.join(os.homedir(), ".dispatch", "media") ), dispatchBinDir: path.resolve(__dirname, "..", "..", "..", "bin"), - codexBin: - process.env.DISPATCH_CODEX_BIN ?? process.env.CODEX_BIN ?? "codex", - claudeBin: - process.env.DISPATCH_CLAUDE_BIN ?? process.env.CLAUDE_BIN ?? "claude", - opencodeBin: + codexBin: resolveConfiguredBin( + process.env.DISPATCH_CODEX_BIN ?? process.env.CODEX_BIN ?? "codex" + ), + claudeBin: resolveConfiguredBin( + process.env.DISPATCH_CLAUDE_BIN ?? process.env.CLAUDE_BIN ?? "claude" + ), + opencodeBin: resolveConfiguredBin( process.env.DISPATCH_OPENCODE_BIN ?? - process.env.OPENCODE_BIN ?? - "opencode", - cursorBin: - process.env.DISPATCH_CURSOR_BIN ?? process.env.CURSOR_BIN ?? "agent", + process.env.OPENCODE_BIN ?? + "opencode" + ), + cursorBin: resolveConfiguredBin( + process.env.DISPATCH_CURSOR_BIN ?? process.env.CURSOR_BIN ?? "agent" + ), + claudeHarnessBin: resolveConfiguredBin( + process.env.DISPATCH_CLAUDE_HARNESS_BIN ?? "claude-agent-acp" + ), + codexHarnessBin: resolveConfiguredBin( + process.env.DISPATCH_CODEX_HARNESS_BIN ?? "codex-acp" + ), + geminiBin: resolveConfiguredBin( + process.env.DISPATCH_GEMINI_BIN ?? process.env.GEMINI_BIN ?? "gemini" + ), agentRuntime: resolveAgentRuntime(), sessionPrefix: process.env.DISPATCH_SESSION_PREFIX ?? "dispatch", tls: loadTls(), diff --git a/apps/server/src/db/migrate.ts b/apps/server/src/db/migrate.ts index 15456eb9d..ad0107e27 100644 --- a/apps/server/src/db/migrate.ts +++ b/apps/server/src/db/migrate.ts @@ -10,8 +10,87 @@ import { migrationFiles } from "../generated/runtime-assets.js"; // Arbitrary fixed key for pg_advisory_lock to prevent concurrent migrations. const MIGRATION_LOCK_ID = 8675309; + const TIMESTAMP_PARSE_NOISE_RE = /^Can't determine timestamp for \d+$/; +/** + * Bookkeeping names written into `pgmigrations` by earlier prereleases of + * this branch, under file names this branch no longer ships. The schema + * those files created is the schema `0051_agent-stream-events.sql` and + * `0052_agent-chat-messages-delivery-text.sql` create now, and both of + * those files re-apply idempotently (every statement is guarded), so the + * dead records are deleted before the runner reads the table. No migration + * can repair this from the inside: node-pg-migrate compares the stored + * list against the shipped files position by position and throws before + * the first migration executes. + */ +const PRERELEASE_MIGRATION_NAMES = [ + "0048_agent-stream-events", + "0049_agent-stream-events-turn", + "0050_agent-chat-messages-delivery-text", + "0051_agent-type-dispatch", + "0052_agent-stream-events-turn", + "0053_agent-chat-messages-delivery-text", + "0054_agent-type-dispatch", +]; + +/** + * Every place the deleted prerelease migration rewrote when the harness + * agent type was renamed: agents, an agent's saved reviewer type, jobs, + * templates, the event log, and the enabled-types setting (a JSON array + * held as text). A value left behind is one no code recognizes, which reads + * as the harness quietly disappearing from that row rather than as an + * error. The old value below is a data literal, and this is the one place + * in the tree where the old name appears. + */ +const PRERELEASE_TYPE_RENAMES = [ + "UPDATE agents SET type = 'dispatch' WHERE type = 'dsh'", + "UPDATE agents SET review_agent_type = 'dispatch' WHERE review_agent_type = 'dsh'", + "UPDATE jobs SET agent_type = 'dispatch' WHERE agent_type = 'dsh'", + "UPDATE templates SET agent_type = 'dispatch' WHERE agent_type = 'dsh'", + "UPDATE agent_events SET agent_type = 'dispatch' WHERE agent_type = 'dsh'", + // No settings row means nobody saved a choice, so this is a no-op then. + `UPDATE settings + SET value = replace(value, '"dsh"', '"dispatch"'), updated_at = NOW() + WHERE key = 'enabled_agent_types' AND value LIKE '%"dsh"%'`, +]; + +/** + * Delete the prerelease bookkeeping records and, when there were any, carry + * over the agent type rename one of those prereleases shipped as a + * migration of its own. Call inside the migration advisory lock, before the + * runner. + */ +async function forgetPrereleaseMigrations(client: pg.Client): Promise { + const table = await client.query<{ oid: string | null }>( + "SELECT to_regclass('pgmigrations')::text AS oid" + ); + if (!table.rows[0]?.oid) return; // fresh database: nothing to forget + + const forgotten = await client.query( + "DELETE FROM pgmigrations WHERE name = ANY($1::text[])", + [PRERELEASE_MIGRATION_NAMES] + ); + if (!forgotten.rowCount) return; + console.log( + `[migrate] forgot ${forgotten.rowCount} prerelease migration record(s)` + ); + + // Those prereleases stored the harness agent type under an older value and + // renamed it in a migration this branch does not ship, so the rename is + // carried over here. Only reached when a record was deleted just above, + // which means the database ran a prerelease and every column below + // exists. + let renamed = 0; + for (const sql of PRERELEASE_TYPE_RENAMES) { + const result = await client.query(sql); + renamed += result.rowCount ?? 0; + } + if (renamed) { + console.log(`[migrate] carried the type rename to ${renamed} row(s)`); + } +} + export interface MigrationOptions { databaseUrl?: string; count?: number; @@ -47,6 +126,7 @@ export async function runMigrations( const migrationsDir = await materializeEmbeddedMigrations(); try { await lockClient.query("SELECT pg_advisory_lock($1)", [MIGRATION_LOCK_ID]); + await forgetPrereleaseMigrations(lockClient); await runner({ databaseUrl: url, diff --git a/apps/server/src/db/migrations/0051_agent-stream-events.sql b/apps/server/src/db/migrations/0051_agent-stream-events.sql new file mode 100644 index 000000000..f94c9271f --- /dev/null +++ b/apps/server/src/db/migrations/0051_agent-stream-events.sql @@ -0,0 +1,29 @@ +-- The Dispatch Harness stream: one row per assistant text, thought, tool +-- call, status line, turn, or plan, folded from Agent Client Protocol +-- session updates. Every statement is guarded so the file re-runs as a +-- no-op on an install whose table predates it; the constraint is replaced +-- rather than created because an older table may carry it without 'plan'. +CREATE TABLE IF NOT EXISTS agent_stream_events ( + id BIGSERIAL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + kind TEXT NOT NULL, + key TEXT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (agent_id, seq) +); + +CREATE UNIQUE INDEX IF NOT EXISTS agent_stream_events_agent_key + ON agent_stream_events (agent_id, kind, key) + WHERE key IS NOT NULL; + +-- The Chat feed reads an agent's newest rows by time (chat/feed.ts). +CREATE INDEX IF NOT EXISTS agent_stream_events_agent_created + ON agent_stream_events (agent_id, created_at DESC, id DESC); + +ALTER TABLE agent_stream_events DROP CONSTRAINT IF EXISTS agent_stream_events_kind_check; +ALTER TABLE agent_stream_events + ADD CONSTRAINT agent_stream_events_kind_check + CHECK (kind IN ('assistant', 'thought', 'tool_call', 'status', 'turn', 'plan')); diff --git a/apps/server/src/db/migrations/0052_agent-chat-messages-delivery-text.sql b/apps/server/src/db/migrations/0052_agent-chat-messages-delivery-text.sql new file mode 100644 index 000000000..6669ce13e --- /dev/null +++ b/apps/server/src/db/migrations/0052_agent-chat-messages-delivery-text.sql @@ -0,0 +1,5 @@ +-- A launch post shows the prompt as the launcher wrote it, but a harness agent's +-- first turn is read from that post (it takes no launch argument). When the +-- turn must carry more than the display text (an MCP launch header, a +-- rendered template), the text to deliver is stored alongside. +ALTER TABLE agent_chat_messages ADD COLUMN IF NOT EXISTS delivery_text TEXT; diff --git a/apps/server/src/db/migrations/0053_agent-stream-events-kind.sql b/apps/server/src/db/migrations/0053_agent-stream-events-kind.sql new file mode 100644 index 000000000..0742347d8 --- /dev/null +++ b/apps/server/src/db/migrations/0053_agent-stream-events-kind.sql @@ -0,0 +1,15 @@ +-- Repair the `kind` CHECK on any install whose agent_stream_events table was +-- created by an earlier prerelease of this branch. That lineage created the +-- table under the file name this branch also ships, so node-pg-migrate (which +-- compares names, not content) skips the shipped file and its constraint +-- replacement never runs; the table is then left allowing every kind except +-- 'plan', and every ACP plan update fails a check violation that the event +-- handler swallows. Deleting the prerelease bookkeeping cannot repair it, +-- because a boot on v0.38.13-harness.2 already deletes those records before +-- the runner reads the table and so erases the only evidence of the lineage. +-- This file runs regardless. Replacing an identical constraint on an install +-- that never saw a prerelease is a scan and no change. +ALTER TABLE agent_stream_events DROP CONSTRAINT IF EXISTS agent_stream_events_kind_check; +ALTER TABLE agent_stream_events + ADD CONSTRAINT agent_stream_events_kind_check + CHECK (kind IN ('assistant', 'thought', 'tool_call', 'status', 'turn', 'plan')); diff --git a/apps/server/src/db/migrations/0054_agent-stream-events-turn-prompt.sql b/apps/server/src/db/migrations/0054_agent-stream-events-turn-prompt.sql new file mode 100644 index 000000000..e61f58746 --- /dev/null +++ b/apps/server/src/db/migrations/0054_agent-stream-events-turn-prompt.sql @@ -0,0 +1,17 @@ +-- The chat feed's anti-join asks, for every row of every page, whether some +-- turn row claims that chat message as its prompt. Nothing indexed that +-- question: `agent_stream_events` carries (agent_id, seq), (agent_id, kind, +-- key) WHERE key IS NOT NULL (turn rows have a null key, so it never +-- applies) and (agent_id, created_at DESC, id DESC). Postgres answered it +-- with a sequential scan that was not even restricted to the agent, so one +-- agent's feed page slowed down as a *different* agent accumulated rows. +-- Measured on a synthetic 60k-row agent: 64.6ms and 8576 buffers without +-- this index, 2.5ms and 413 with it. +-- +-- Partial and expression-based so it indexes only what the anti-join reads. +-- The expression must stay in step with TURN_PROMPT_CHAT_ID_PATH in +-- apps/server/src/chat/turns.ts, which is where the same path is spelled for +-- the query itself. +CREATE INDEX IF NOT EXISTS agent_stream_events_turn_prompt + ON agent_stream_events (agent_id, ((payload->'prompt'->>'chatMessageId'))) + WHERE kind = 'turn'; diff --git a/apps/server/src/db/migrations/0055_dispatch-harness-carry-over.sql b/apps/server/src/db/migrations/0055_dispatch-harness-carry-over.sql new file mode 100644 index 000000000..3a0a472c5 --- /dev/null +++ b/apps/server/src/db/migrations/0055_dispatch-harness-carry-over.sql @@ -0,0 +1,26 @@ +-- Carry a prerelease install's harness opt-in onto the flag that replaced it. +-- +-- On v0.38.13-harness.2 and .3 the Dispatch Harness was turned on by adding +-- `dispatch` to the `enabled_agent_types` setting. It is now its own flag, +-- `dispatch_harness_enabled`, which defaults to off, and +-- `sanitizeEnabledAgentTypes` strips `dispatch` from the stored list on +-- read. Without this statement an install that had the harness on loses it +-- on update with nothing said: the type disappears from the create dialog, +-- and a *running* dispatch parent's dispatch_launch_persona starts throwing +-- "dispatch agents are disabled in settings" mid-turn, because a persona +-- defaults to its parent's own type. +-- +-- ON CONFLICT DO NOTHING so an operator who has already set the new flag +-- either way keeps their choice. No settings row, or a row without +-- `dispatch` in it, means nobody opted in and this is a no-op. +-- +-- Known residual: an install whose list was exactly `["dispatch"]` gets the +-- harness back here, but its list still sanitizes to empty and so falls back +-- to the default types. That fallback predates this work and cannot tell +-- "the operator chose only the harness" from "nobody chose". +INSERT INTO settings (key, value, updated_at) +SELECT 'dispatch_harness_enabled', 'true', NOW() + FROM settings + WHERE key = 'enabled_agent_types' + AND value LIKE '%"dispatch"%' +ON CONFLICT (key) DO NOTHING; diff --git a/apps/server/src/db/migrations/0056_agents-chat-read-at.sql b/apps/server/src/db/migrations/0056_agents-chat-read-at.sql new file mode 100644 index 000000000..5b595c656 --- /dev/null +++ b/apps/server/src/db/migrations/0056_agents-chat-read-at.sql @@ -0,0 +1,17 @@ +-- When the user last read an agent's chat feed. +-- +-- Unread was counted purely over `agent_chat_messages`, which works for a +-- CLI agent whose replies are chat rows. A Dispatch Harness agent's answers +-- are turns assembled from `agent_stream_events` and never chat rows, and +-- its persona is told not to repeat a reply through dispatch_chat_post, so +-- the badge could only ever fire for a question it asked. Counting turns +-- needs a per-agent watermark, because turns carry no per-row read state the +-- way chat messages do. +-- +-- Existing agents are stamped with now() so a backlog of settled turns does +-- not light up every badge on the first load after the update, and new rows +-- default the same way: an agent is "read" as of the moment it exists, and +-- anything that settles after that is news. +ALTER TABLE agents ADD COLUMN IF NOT EXISTS chat_read_at TIMESTAMPTZ; +UPDATE agents SET chat_read_at = NOW() WHERE chat_read_at IS NULL; +ALTER TABLE agents ALTER COLUMN chat_read_at SET DEFAULT NOW(); diff --git a/apps/server/src/db/migrations/0051_agent-chat-reactions.sql b/apps/server/src/db/migrations/0057_agent-chat-reactions.sql similarity index 100% rename from apps/server/src/db/migrations/0051_agent-chat-reactions.sql rename to apps/server/src/db/migrations/0057_agent-chat-reactions.sql diff --git a/apps/server/src/dispatch-harness-settings.ts b/apps/server/src/dispatch-harness-settings.ts new file mode 100644 index 000000000..61e9efa4a --- /dev/null +++ b/apps/server/src/dispatch-harness-settings.ts @@ -0,0 +1,29 @@ +import type { Pool } from "pg"; + +import { getSetting, setSetting } from "./db/settings.js"; + +/** + * Whether the Dispatch Harness agent type (`dispatch`) is offered anywhere: + * the create dialog, the sidebar picker, jobs, templates, reviewer pickers, + * `dispatch_launch_agent` and persona launches. Off by default, since the + * harness needs an engine's CLI installed and logged in on the server. + * + * The one switch: `dispatch` is deliberately not a member of + * `enabled_agent_types` (see `sanitizeEnabledAgentTypes`), so there is no + * second place to turn the harness on. + * + * Gates creation and discovery only. Turning it off leaves running dispatch + * agents running, and `/api/v1/agents/:id/harness/*` keeps serving them. + */ +const DISPATCH_HARNESS_KEY = "dispatch_harness_enabled"; + +export async function isDispatchHarnessEnabled(pool: Pool): Promise { + return (await getSetting(pool, DISPATCH_HARNESS_KEY)) === "true"; +} + +export async function setDispatchHarnessEnabled( + pool: Pool, + enabled: boolean +): Promise { + await setSetting(pool, DISPATCH_HARNESS_KEY, enabled ? "true" : "false"); +} diff --git a/apps/server/src/routes/agents/crud-routes.ts b/apps/server/src/routes/agents/crud-routes.ts index ef2fc8993..7f8c19e6f 100644 --- a/apps/server/src/routes/agents/crud-routes.ts +++ b/apps/server/src/routes/agents/crud-routes.ts @@ -7,7 +7,7 @@ import type { FastifyInstance } from "fastify"; import { AGENT_TYPES, type AgentType, - getEnabledAgentTypes, + getOfferedAgentTypes, } from "../../agent-type-settings.js"; import { getWorktreeLocation } from "../../worktree-location-settings.js"; import { @@ -27,6 +27,7 @@ import { type AgentRouteDeps, } from "./shared.js"; import { validateAgentModel } from "../../shared/agent-models.js"; +import { DEFAULT_HARNESS_MODEL } from "@dispatch/shared"; export async function registerAgentCrudRoutes( app: FastifyInstance, @@ -253,8 +254,10 @@ export async function registerAgentCrudRoutes( body.type && AGENT_TYPES.includes(body.type as AgentType) ? (body.type as AgentType) : "codex"; - const enabledAgentTypes = await getEnabledAgentTypes(deps.pool); - if (!enabledAgentTypes.includes(agentType)) { + // The offered list, not the enabled one: `dispatch` is never a member of + // the persisted enabled types and arrives from the harness flag instead. + const offeredAgentTypes = await getOfferedAgentTypes(deps.pool); + if (!offeredAgentTypes.includes(agentType)) { return reply .code(400) .send({ error: `${agentType} agents are disabled in settings.` }); @@ -270,6 +273,12 @@ export async function registerAgentCrudRoutes( } catch (error) { return reply.code(400).send({ error: errorMessage(error) }); } + // "Default (CLI setting)" leaves the model unset, which for a harness + // agent would leave nothing to derive an engine from. Store the default + // harness model instead, the engine the child runs either way. + if (agentType === "dispatch" && model === undefined) { + model = DEFAULT_HARNESS_MODEL; + } const fullAccessArg = agentType === "claude" ? CLAUDE_FULL_ACCESS_ARG diff --git a/apps/server/src/routes/agents/harness-routes.ts b/apps/server/src/routes/agents/harness-routes.ts new file mode 100644 index 000000000..efdc77480 --- /dev/null +++ b/apps/server/src/routes/agents/harness-routes.ts @@ -0,0 +1,188 @@ +import type { FastifyInstance } from "fastify"; +import type { + HarnessCommandsResponse, + HarnessConfigResponse, + HarnessConfigUpdateRequest, + HarnessPathsResponse, + HarnessQueueResponse, +} from "@dispatch/shared"; + +import { listHarnessPaths } from "../../agents/harness/paths.js"; +import { loadQueued } from "../../chat/turns.js"; +import { loadAgentUsage, monthStartUtc } from "../../agents/harness/usage.js"; +import type { AgentRouteDeps } from "./shared.js"; + +export async function registerAgentHarnessRoutes( + app: FastifyInstance, + deps: Pick +): Promise { + const exists = async (id: string): Promise => { + const row = await deps.pool.query( + "SELECT 1 FROM agents WHERE id = $1 AND deleted_at IS NULL", + [id] + ); + return row.rows.length > 0; + }; + + app.get("/api/v1/agents/:id/harness/config", async (request, reply) => { + const id = (request.params as { id?: string }).id ?? ""; + if (!(await exists(id))) { + return reply.code(404).send({ error: "Agent not found." }); + } + const options = deps.harness.getConfigOptions(id); + const sessionStartedAt = deps.harness.getSessionStartedAt(id); + const response: HarnessConfigResponse = { + running: options !== null, + ...(sessionStartedAt ? { sessionStartedAt } : {}), + options: options ?? [], + }; + return response; + }); + + app.put("/api/v1/agents/:id/harness/config", async (request, reply) => { + const id = (request.params as { id?: string }).id ?? ""; + const body = (request.body ?? {}) as Partial; + if ( + typeof body.configId !== "string" || + !body.configId || + typeof body.value !== "string" + ) { + return reply + .code(400) + .send({ error: "configId and value are required." }); + } + if (!(await exists(id))) { + return reply.code(404).send({ error: "Agent not found." }); + } + if (deps.harness.getConfigOptions(id) === null) { + return reply.code(409).send({ error: "The agent is not running." }); + } + try { + const options = await deps.harness.setConfigOption( + id, + body.configId, + body.value + ); + const sessionStartedAt = deps.harness.getSessionStartedAt(id); + const response: HarnessConfigResponse = { + running: true, + ...(sessionStartedAt ? { sessionStartedAt } : {}), + options, + }; + return response; + } catch (err) { + return reply + .code(400) + .send({ error: err instanceof Error ? err.message : String(err) }); + } + }); + // What waits behind the running turn. In-memory supervisor state, not a + // feed row: the composer reads it from here rather than from the turns. + app.get("/api/v1/agents/:id/harness/queue", async (request, reply) => { + const id = (request.params as { id?: string }).id ?? ""; + if (!(await exists(id))) { + return reply.code(404).send({ error: "Agent not found." }); + } + const response: HarnessQueueResponse = { + queued: await loadQueued(deps.pool, deps.harness.listQueued(id)), + }; + return response; + }); + + app.post( + "/api/v1/agents/:id/harness/queue/:queuedId/send-now", + async (request, reply) => { + const { id = "", queuedId = "" } = request.params as { + id?: string; + queuedId?: string; + }; + if (!(await exists(id))) { + return reply.code(404).send({ error: "Agent not found." }); + } + if (!(await deps.harness.sendQueuedNow(id, queuedId))) { + return reply + .code(404) + .send({ error: "That message is no longer queued." }); + } + return reply.code(204).send(); + } + ); + + app.delete( + "/api/v1/agents/:id/harness/queue/:queuedId", + async (request, reply) => { + const { id = "", queuedId = "" } = request.params as { + id?: string; + queuedId?: string; + }; + if (!(await exists(id))) { + return reply.code(404).send({ error: "Agent not found." }); + } + if (!deps.harness.removeQueued(id, queuedId)) { + return reply + .code(404) + .send({ error: "That message is no longer queued." }); + } + return reply.code(204).send(); + } + ); + + app.post("/api/v1/agents/:id/harness/interrupt", async (request, reply) => { + const id = (request.params as { id?: string }).id ?? ""; + if (!(await exists(id))) { + return reply.code(404).send({ error: "Agent not found." }); + } + if (!(await deps.harness.interrupt(id))) { + return reply.code(409).send({ error: "No turn is running." }); + } + return reply.code(204).send(); + }); + + app.get("/api/v1/agents/:id/harness/commands", async (request, reply) => { + const id = (request.params as { id?: string }).id ?? ""; + if (!(await exists(id))) { + return reply.code(404).send({ error: "Agent not found." }); + } + const response: HarnessCommandsResponse = { + commands: deps.harness.getCommands(id) ?? [], + }; + return response; + }); + + app.get("/api/v1/agents/:id/harness/usage", async (request, reply) => { + const id = (request.params as { id?: string }).id ?? ""; + if (!(await exists(id))) { + return reply.code(404).send({ error: "Agent not found." }); + } + return { + agent: await loadAgentUsage(deps.pool, id), + monthStart: monthStartUtc().toISOString(), + }; + }); + + const agentWorkingDir = async (id: string): Promise => { + const row = await deps.pool.query<{ + cwd: string; + worktree_path: string | null; + }>( + "SELECT cwd, worktree_path FROM agents WHERE id = $1 AND deleted_at IS NULL", + [id] + ); + const agent = row.rows[0]; + return agent ? (agent.worktree_path ?? agent.cwd) : null; + }; + + app.get("/api/v1/agents/:id/harness/paths", async (request, reply) => { + const id = (request.params as { id?: string }).id ?? ""; + const q = (request.query as { q?: unknown }).q; + const query = typeof q === "string" ? q : ""; + const cwd = await agentWorkingDir(id); + if (cwd === null) { + return reply.code(404).send({ error: "Agent not found." }); + } + const response: HarnessPathsResponse = { + paths: await listHarnessPaths(query, { cwd }), + }; + return response; + }); +} diff --git a/apps/server/src/routes/agents/index.ts b/apps/server/src/routes/agents/index.ts index c95e2d13f..93c54556c 100644 --- a/apps/server/src/routes/agents/index.ts +++ b/apps/server/src/routes/agents/index.ts @@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify"; import type { AgentRouteDeps } from "./shared.js"; import { registerAgentCrudRoutes } from "./crud-routes.js"; import { registerAgentEventRoutes } from "./events-routes.js"; +import { registerAgentHarnessRoutes } from "./harness-routes.js"; import { registerAgentLifecycleRoutes } from "./lifecycle-routes.js"; import { registerAgentStreamingRoutes } from "./streaming-routes.js"; import { registerAgentTerminalRoutes } from "./terminal-routes.js"; @@ -18,4 +19,5 @@ export async function registerAgentRoutes( await registerAgentLifecycleRoutes(app, deps); await registerAgentStreamingRoutes(app, deps); await registerAgentTerminalRoutes(app, deps); + await registerAgentHarnessRoutes(app, deps); } diff --git a/apps/server/src/routes/agents/lifecycle-routes.ts b/apps/server/src/routes/agents/lifecycle-routes.ts index 670fda08c..222e8bbdb 100644 --- a/apps/server/src/routes/agents/lifecycle-routes.ts +++ b/apps/server/src/routes/agents/lifecycle-routes.ts @@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify"; import { CLI_AGENT_TYPES, - getEnabledAgentTypes, + getOfferedAgentTypes, } from "../../agent-type-settings.js"; import { AGENT_LATEST_EVENT_TYPES, @@ -42,8 +42,11 @@ export async function registerAgentLifecycleRoutes( } if (reviewAgentType) { - const enabledAgentTypes = await getEnabledAgentTypes(deps.pool); - if (!enabledAgentTypes.includes(reviewAgentType)) { + // The reviewer picker offers what the create dialog offers, so this + // gate reads the same offered list: the enabled types plus `dispatch` + // when the Dispatch Harness flag is on. + const offeredAgentTypes = await getOfferedAgentTypes(deps.pool); + if (!offeredAgentTypes.includes(reviewAgentType)) { return reply.code(400).send({ error: `${reviewAgentType} agents are disabled in settings.`, }); diff --git a/apps/server/src/routes/agents/shared.ts b/apps/server/src/routes/agents/shared.ts index 2f7cc3710..28f7cfd74 100644 --- a/apps/server/src/routes/agents/shared.ts +++ b/apps/server/src/routes/agents/shared.ts @@ -1,3 +1,5 @@ +import type { HarnessCommand, HarnessConfigOption } from "@dispatch/shared"; +import type { QueuedPrompt } from "../../agents/harness/prompt-source.js"; import type { FastifyBaseLogger, FastifyReply } from "fastify"; import type { Pool } from "pg"; import type WebSocket from "ws"; @@ -20,6 +22,25 @@ export const CLAUDE_FULL_ACCESS_ARG = "--dangerously-skip-permissions"; export type AgentRouteDeps = { pool: Pool; + /** Session config (model, effort) for Dispatch Harness agents. */ + harness: { + getConfigOptions: (agentId: string) => HarnessConfigOption[] | null; + getSessionStartedAt: (agentId: string) => string | null; + setConfigOption: ( + agentId: string, + configId: string, + value: string + ) => Promise; + /** The slash commands the engine advertised; null when not running. */ + getCommands: (agentId: string) => HarnessCommand[] | null; + /** Prompts waiting behind the running turn (HarnessSupervisor.listQueued). */ + listQueued: (agentId: string) => QueuedPrompt[]; + /** Promote and interrupt; false when nothing queued has that id. */ + sendQueuedNow: (agentId: string, id: string) => Promise; + removeQueued: (agentId: string, id: string) => boolean; + /** Cancel the running turn; false when nothing runs. */ + interrupt: (agentId: string) => Promise; + }; appLog: FastifyBaseLogger; agentManager: AgentManager; publishUiEvent: PublishUiEvent; diff --git a/apps/server/src/routes/chat.ts b/apps/server/src/routes/chat.ts index eb0fdf45d..db1044301 100644 --- a/apps/server/src/routes/chat.ts +++ b/apps/server/src/routes/chat.ts @@ -228,12 +228,19 @@ export async function registerChatRoutes( if (!(await agentExists(id))) { return reply.code(404).send({ error: "Agent not found." }); } + const before = await store.countUnread(id); const marked = await store.markRead(id, upTo ?? undefined); + // Turns carry no per-row read state, so every read also moves the + // agent's watermark, which is what they are counted against. + await store.markFeedRead(id); const unreadCount = await store.countUnread(id); - if (marked.updated > 0 && marked.readAt !== null) { + // The count can fall without a single chat row changing, on an agent + // whose output is turns, so the announcement follows the count rather + // than the rows. + if (marked.updated > 0 || unreadCount !== before) { chat.publishRead(id, { unreadCount, - readAt: marked.readAt, + readAt: marked.readAt ?? new Date().toISOString(), upToAt: marked.upToAt, }); } diff --git a/apps/server/src/routes/plugin.ts b/apps/server/src/routes/plugin.ts index 1db360d29..07dbcb14c 100644 --- a/apps/server/src/routes/plugin.ts +++ b/apps/server/src/routes/plugin.ts @@ -1,7 +1,7 @@ import type { FastifyBaseLogger, FastifyInstance } from "fastify"; import type { Pool } from "pg"; -import { getEnabledAgentTypes } from "../agent-type-settings.js"; +import { getOfferedAgentTypes } from "../agent-type-settings.js"; import { CLI_BY_AGENT_TYPE } from "../agents/tmux/command-builder.js"; import type { AppConfig } from "../config.js"; import { @@ -30,9 +30,12 @@ export async function registerPluginRoutes( const query = request.query as { refresh?: unknown }; const forceRefresh = query?.refresh === "true" || query?.refresh === "1"; - const enabledAgentTypes = await getEnabledAgentTypes(deps.pool); + // The offered list, like every other gate. `PLUGIN_AGENT_TYPES` is claude + // and codex, so the harness can never be one of these either way; reading + // one list everywhere is what stops a later reader having to check which. + const offeredAgentTypes = await getOfferedAgentTypes(deps.pool); const applicableTypes = PLUGIN_AGENT_TYPES.filter((type) => - enabledAgentTypes.includes(type) + offeredAgentTypes.includes(type) ); const statuses = await Promise.all( @@ -53,8 +56,8 @@ export async function registerPluginRoutes( } const agentType = body.agentType; - const enabledAgentTypes = await getEnabledAgentTypes(deps.pool); - if (!enabledAgentTypes.includes(agentType)) { + const offeredAgentTypes = await getOfferedAgentTypes(deps.pool); + if (!offeredAgentTypes.includes(agentType)) { return reply .code(400) .send({ error: `${agentType} is not an enabled agent type.` }); diff --git a/apps/server/src/routes/release.ts b/apps/server/src/routes/release.ts index c1c881031..22fe83ff4 100644 --- a/apps/server/src/routes/release.ts +++ b/apps/server/src/routes/release.ts @@ -8,7 +8,7 @@ import type { Pool } from "pg"; import type { AgentManager, AgentRecord } from "../agents/manager.js"; import { - getEnabledAgentTypes, + getOfferedAgentTypes, isCliAgentType, } from "../agent-type-settings.js"; import { getReleaseUpdateAgentId } from "../auth.js"; @@ -715,8 +715,13 @@ async function handleAssistedLaunch( }); } - const enabledAgentTypes = await getEnabledAgentTypes(deps.pool); - const assistedType = enabledAgentTypes.find(isCliAgentType); + const offeredAgentTypes = await getOfferedAgentTypes(deps.pool); + // A Dispatch Harness agent is never the driver: the update restarts the + // service that owns its engine child, which cuts the agent's own turn. The + // exclusion stays even though the type now arrives from its own flag. + const assistedType = offeredAgentTypes.find( + (type) => isCliAgentType(type) && type !== "dispatch" + ); if (!assistedType) { return reply.code(422).send({ error: diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index 7c1ffcfbb..ef2f76820 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -3,6 +3,11 @@ import path from "node:path"; import { readdir, stat } from "node:fs/promises"; import type { FastifyBaseLogger, FastifyInstance } from "fastify"; +import type { + HarnessAuthReport, + HarnessProviderUsageReport, + HarnessUsageReport, +} from "@dispatch/shared"; import type { Pool } from "pg"; import { deleteSetting, getSetting, setSetting } from "../db/settings.js"; @@ -22,6 +27,15 @@ import { isChatSurfaceEnabled, setChatSurfaceEnabled, } from "../chat-surface-settings.js"; +import { + isDispatchHarnessEnabled, + setDispatchHarnessEnabled, +} from "../dispatch-harness-settings.js"; +import { + getUsageBudgets, + parseUsageBudgets, + setUsageBudgets, +} from "../usage-budget-settings.js"; import { JobService } from "../jobs/service.js"; import { AGENT_TYPES, @@ -53,6 +67,9 @@ type SystemRouteDeps = { validIconColors: readonly string[]; getCachedIconColor: () => string; rewriteForColor: (color: string) => void; + usageReport?: () => Promise; + authReport?: () => Promise; + providerUsageReport?: () => Promise; }; export async function registerSystemRoutes( @@ -374,6 +391,16 @@ export async function registerSystemRoutes( .send({ error: "enabledAgentTypes must be an array." }); } + // `dispatch` is a member of AGENT_TYPES, so without this the body would + // pass validation and then be sanitized away, answering 200 with a list + // that silently lacks what was asked for. + if (body.enabledAgentTypes.includes("dispatch")) { + return reply.code(400).send({ + error: + "dispatch is not set here. Turn the Dispatch Harness on or off at POST /api/v1/app/settings/dispatch-harness.", + }); + } + const uniqueTypes = body.enabledAgentTypes .filter( (value): value is (typeof AGENT_TYPES)[number] => @@ -442,6 +469,43 @@ export async function registerSystemRoutes( return { enabled: body.enabled }; }); + // Service-wide, not per agent: the keys are the service's. Cached a + // minute upstream. + app.get("/api/v1/harness/usage", async (_request, reply) => { + if (!deps.usageReport) { + return reply.code(503).send({ error: "Usage reporting is not wired." }); + } + const response: HarnessUsageReport = await deps.usageReport(); + return response; + }); + + app.get("/api/v1/harness/auth", async (_request, reply) => { + if (!deps.authReport) { + return reply.code(503).send({ error: "Auth reporting is not wired." }); + } + return await deps.authReport(); + }); + + app.get("/api/v1/harness/provider-usage", async (_request, reply) => { + if (!deps.providerUsageReport) { + return reply + .code(503) + .send({ error: "Provider usage reporting is not wired." }); + } + return await deps.providerUsageReport(); + }); + + app.get("/api/v1/app/settings/usage-budgets", async () => { + return { budgets: await getUsageBudgets(deps.pool) }; + }); + + app.post("/api/v1/app/settings/usage-budgets", async (request, reply) => { + const body = request.body as { budgets?: unknown } | null; + const parsed = parseUsageBudgets(body?.budgets); + if (!parsed.ok) return reply.code(400).send({ error: parsed.error }); + return { budgets: await setUsageBudgets(deps.pool, parsed.budgets) }; + }); + app.get("/api/v1/app/settings/chat-surface", async () => { return { enabled: await isChatSurfaceEnabled(deps.pool) }; }); @@ -455,6 +519,21 @@ export async function registerSystemRoutes( return { enabled: body.enabled }; }); + // `dispatch` is never a member of `enabled_agent_types`; this is the + // only switch that turns the harness on. + app.get("/api/v1/app/settings/dispatch-harness", async () => { + return { enabled: await isDispatchHarnessEnabled(deps.pool) }; + }); + + app.post("/api/v1/app/settings/dispatch-harness", async (request, reply) => { + const body = request.body as { enabled?: unknown } | null; + if (typeof body?.enabled !== "boolean") { + return reply.code(400).send({ error: "enabled must be a boolean." }); + } + await setDispatchHarnessEnabled(deps.pool, body.enabled); + return { enabled: body.enabled }; + }); + app.get("/api/v1/app/settings/launch-guidance-trim", async () => { return { enabled: await isTrimmedLaunchGuidanceEnabled(deps.pool) }; }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7bae5c957..3e33d3b6c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -18,6 +18,10 @@ import Fastify from "fastify"; import * as z from "zod/v4"; import { AgentManager } from "./agents/manager.js"; +import { HarnessSupervisor } from "./agents/harness/supervisor.js"; +import { loadUsageReport } from "./agents/harness/usage.js"; +import { createHarnessAuthReporter } from "./agents/harness/auth-status.js"; +import { getUsageBudgets } from "./usage-budget-settings.js"; import type { AgentRecord } from "./agents/manager.js"; import { validateSession, @@ -124,6 +128,7 @@ import { registerReleaseRoutes } from "./routes/release.js"; import { createAutoCheckRuntime } from "./release-auto-check.js"; import { registerStaticRoutes } from "./routes/static.js"; import { registerSystemRoutes } from "./routes/system.js"; +import { createHarnessProviderUsageReporter } from "./agents/harness/provider-usage.js"; import { registerPluginRoutes } from "./routes/plugin.js"; import { registerResourceRoutes } from "./routes/resources.js"; import { SurfaceService } from "./surfaces/service.js"; @@ -454,6 +459,7 @@ const surfaceService = new SurfaceService(pool, { const chatService = new ChatService({ pool, publishUiEvent: (event) => uiEventBroker.publish(event), + hasUiClient: () => uiEventBroker.hasConnectedClient(), getAgent: (agentId) => agentManager.getAgent(agentId), mediaRoot: config.mediaRoot, delivery: { @@ -463,11 +469,52 @@ const chatService = new ChatService({ // failed delivery rather than a stale session name. inject: async (agentId, _sessionName, text) => (await enqueueAgentPrompt(agentId, text)).delivery, - held: (agentId) => injectionCoordinator.holdState(agentId).held, + // A harness prompt waits in the supervisor's turn queue, not the + // injection gate; report that so Chat and MCP messages agree on "held". + held: (agentId): boolean => + harnessSupervisor.isRunning(agentId) + ? harnessSupervisor.isBusy(agentId) + : injectionCoordinator.holdState(agentId).held, }, log: app.log, }); agentManager.attachLaunchContextRecorder(chatService); +const harnessSupervisor = new HarnessSupervisor({ + pool, + config, + logger: app.log, + getAgent: (agentId) => agentManager.getAgent(agentId), + setCliSessionId: (agentId, sessionId) => + agentManager.setCliSessionId(agentId, sessionId), + setLatestEvent: async (agentId, input) => { + await agentManager.upsertLatestEvent(agentId, input); + }, + // Every flush announces itself twice, and the halves carry different + // things: harness.changed carries the queue (and the session config when + // the write changed it), and the affected turn goes out as one feed row. + publishHarness: (agentId, config) => { + chatService.publishHarnessChanged(agentId, config); + void chatService.publishTurnEntry(agentId); + }, + personaPromptFor: (agent, jobRunId) => + agentManager.buildHarnessPersonaFor(agent, jobRunId ?? undefined), + activeJobRunIdFor: async (agentId) => + (await jobService.getActiveRunForAgent(agentId))?.id ?? null, + launchPromptFor: (agentId): Promise => + chatService.launchPromptFor(agentId), + listRunningAgentIds: () => agentManager.listRunningHarnessAgentIds(), + markStartFailed: (agentId, message) => + agentManager.markHarnessStartFailed(agentId, message), + markExited: (agentId, message) => + agentManager.markHarnessExited(agentId, message), + setAgentModel: async (agentId, model) => { + await pool.query("UPDATE agents SET model = $2 WHERE id = $1", [ + agentId, + model, + ]); + }, +}); +agentManager.attachHarnessSupervisor(harnessSupervisor); jobService.setBrainStore(brainStore); const mcpHandlers = createMcpHandlers({ pool, @@ -716,6 +763,13 @@ async function registerRoutes() { chat: chatService, }); + const harnessAuthReport = createHarnessAuthReporter({ + claude: config.claudeBin, + codex: config.codexBin, + gemini: config.geminiBin, + opencode: config.opencodeBin, + }); + const harnessProviderUsageReport = createHarnessProviderUsageReporter(); await registerSystemRoutes(app, { pool, appLog: app.log, @@ -724,6 +778,9 @@ async function registerRoutes() { validIconColors: VALID_ICON_COLORS, getCachedIconColor: staticTheme.getCachedIconColor, rewriteForColor: (color) => staticTheme.rewriteForColor(color as IconColor), + usageReport: async () => loadUsageReport(pool, await getUsageBudgets(pool)), + authReport: harnessAuthReport, + providerUsageReport: harnessProviderUsageReport, }); await registerResourceRoutes(app, { pool, resources: serviceResources }); @@ -820,6 +877,21 @@ async function registerRoutes() { await registerAgentRoutes(app, { pool, + harness: { + getConfigOptions: (agentId) => + harnessSupervisor.getConfigOptions(agentId), + getSessionStartedAt: (agentId) => + harnessSupervisor.getSessionStartedAt(agentId), + setConfigOption: (agentId, configId, value) => + harnessSupervisor.setConfigOption(agentId, configId, value), + getCommands: (agentId) => harnessSupervisor.getCommands(agentId), + listQueued: (agentId) => harnessSupervisor.listQueued(agentId), + sendQueuedNow: (agentId, id) => + harnessSupervisor.sendQueuedNow(agentId, id), + removeQueued: (agentId, id) => + harnessSupervisor.removeQueued(agentId, id), + interrupt: (agentId) => harnessSupervisor.interrupt(agentId), + }, appLog: app.log, agentManager, publishUiEvent: (event) => uiEventBroker.publish(event), @@ -919,6 +991,7 @@ export async function initializeApp(options?: { await readServiceResourcesCollectionEnabled(pool) ); const shouldReconcileState = options?.reconcileState ?? true; + reconcileOnStart = shouldReconcileState; if (shouldReconcileState) { await agentManager.reconcileAgents(); // Chat deliveries queued in the previous process died with it; flip their @@ -972,6 +1045,9 @@ export async function closeApp(): Promise { await cleanupAppResources(); } +/** Whether initializeApp reconciled state; start() finishes that work after listen. */ +let reconcileOnStart = true; + export async function start() { await initializeApp(); @@ -986,6 +1062,12 @@ export async function start() { // The process that activated a new binary exits during the service restart, // so only this newly healthy process can truthfully promote the candidate. + // It goes before the harness restore below and never behind it: promotion + // only needs a listening, healthy process, while the restore starts agents + // one at a time under a 30 s handshake ceiling each. Behind the restore, + // release.json still named the previous tag for minutes on a busy install, + // and an assisted update checking version convergence in that window + // blocked against a perfectly healthy server. try { const promoted = await promoteHealthyReleaseCandidate({ expectedTag: `v${packageVersion}`, @@ -995,6 +1077,33 @@ export async function start() { } catch (err) { app.log.error({ err }, "Failed to promote healthy release candidate"); } + + // Harness children died with the previous process while their agents + // stayed "running"; bring them back on their stored session ids. This has + // to run after listen: the harness attaches Dispatch's MCP endpoint at + // resume. + if (reconcileOnStart) { + const harnessRestore = await harnessSupervisor.restoreRunning(); + // Chat messages that were queued behind a running turn when the last + // process stopped: the boot sweep left them pending for these agents. + for (const id of harnessRestore.restored) { + try { + const count = await chatService.redeliverPending(id); + if (count > 0) { + app.log.info( + { agentId: id, count }, + "Redelivered queued chat messages" + ); + } + } catch (err) { + app.log.warn({ err, agentId: id }, "Redelivering queued chat failed"); + } + } + await chatService.abandonPending(harnessRestore.failed).catch(() => []); + if (harnessRestore.restored.length + harnessRestore.failed.length > 0) { + app.log.info(harnessRestore, "Restored harness agents after restart"); + } + } } export { app, shutdown }; @@ -1025,6 +1134,13 @@ async function cleanupAppResources(): Promise { ); } + // Stop harness children through their teardown ladder before the pool + // goes away (the exit rows need it); otherwise they outlive the server + // with full-access permissions and a stale MCP token. + await harnessSupervisor.stopAll().catch((err: unknown) => { + app.log.warn({ err }, "Stopping harness agents on shutdown failed"); + }); + await pool.end().catch(() => null); await app.close().catch(() => null); } diff --git a/apps/server/src/server/agent-prompts.ts b/apps/server/src/server/agent-prompts.ts index 7e52083d6..fe6383224 100644 --- a/apps/server/src/server/agent-prompts.ts +++ b/apps/server/src/server/agent-prompts.ts @@ -36,13 +36,23 @@ export function createPromptInjector( prompt, opts = {} ) => { - const access = await agentManager.getTerminalAccess(agentId); - if (access.mode !== "tmux") { + const target = await agentManager.getPromptTarget(agentId); + if (target.kind === "harness") { + // One turn at a time: a prompt that lands mid-turn is held until the + // running turn settles, then delivered as the next turn. "Delivered" + // means the turn started, which is what pane injection promises too. + const { started, settled } = agentManager.promptHarness(agentId, prompt); + settled.catch((error) => { + appLog.warn({ err: error, agentId }, "harness turn failed"); + }); + return { held: target.busy, delivery: started }; + } + if (target.kind !== "tmux") { throw new Error( "Agent has no active terminal session — prompt cannot be delivered." ); } - const terminal = new TmuxTerminal(access.sessionName); + const terminal = new TmuxTerminal(target.sessionName); const delivery = coordinator.inject( agentId, () => terminal.sendCommand(prompt), diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 9aea908e9..a14eee273 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -12,9 +12,12 @@ import { mediaMetadataFromBuffer } from "../media/metadata.js"; import type { AgentPin, WorktreeCleanupMode } from "../agents/types.js"; import { CLI_AGENT_TYPES, - getEnabledAgentTypes, + getOfferedAgentTypes, } from "../agent-type-settings.js"; -import { validateAgentModel } from "../shared/agent-models.js"; +import { + inheritedHarnessModel, + validateAgentModel, +} from "../shared/agent-models.js"; import { isCrossRepoMessagingEnabled } from "../cross-repo-messaging-settings.js"; import type { JobService } from "../jobs/service.js"; import type { TemplateService } from "../templates/service.js"; @@ -571,9 +574,12 @@ async function handleLaunchAgent( ); } - const enabledAgentTypes = await getEnabledAgentTypes(deps.pool); + // The offered list: the persisted enabled types plus `dispatch` when the + // Dispatch Harness flag is on. A parent may launch a harness child exactly + // when the create dialog would offer the type. + const offeredAgentTypes = await getOfferedAgentTypes(deps.pool); if ( - !enabledAgentTypes.includes(agentType as (typeof CLI_AGENT_TYPES)[number]) + !offeredAgentTypes.includes(agentType as (typeof CLI_AGENT_TYPES)[number]) ) { throw new Error(`${agentType} agents are disabled in settings.`); } @@ -610,10 +616,17 @@ async function handleLaunchAgent( const worktreeLocation = await getWorktreeLocation(deps.pool); const cliSessionId = agentType === "claude" ? randomUUID() : undefined; - const model = validateAgentModel( - agentType as (typeof CLI_AGENT_TYPES)[number], - input.model - ); + // A harness child inherits its parent's engine when the caller named no + // model, for the same reason a persona does: the engine is half the id. + const model = + validateAgentModel( + agentType as (typeof CLI_AGENT_TYPES)[number], + input.model + ) ?? + inheritedHarnessModel( + agentType as (typeof CLI_AGENT_TYPES)[number], + parent + ); // Launching a template is a request for the template's own instructions — // without this the caller's short prompt was the agent's entire prompt and diff --git a/apps/server/src/server/mcp-review-handlers.ts b/apps/server/src/server/mcp-review-handlers.ts index 5fab0ebc0..29191b51e 100644 --- a/apps/server/src/server/mcp-review-handlers.ts +++ b/apps/server/src/server/mcp-review-handlers.ts @@ -6,7 +6,7 @@ import type { Pool } from "pg"; import type { AgentManager, AgentRecord } from "../agents/manager.js"; import { CLI_AGENT_TYPES, - getEnabledAgentTypes, + getOfferedAgentTypes, isCliAgentType, } from "../agent-type-settings.js"; import { getBuiltInPersona } from "../personas/built-in.js"; @@ -31,7 +31,10 @@ import { resolveRepoRoot, resolveWorktreeRoot, } from "../shared/git/git-context.js"; -import { validateAgentModel } from "../shared/agent-models.js"; +import { + inheritedHarnessModel, + validateAgentModel, +} from "../shared/agent-models.js"; import { getPrStatus } from "../shared/github/pr.js"; import { runCommand } from "../shared/lib/run-command.js"; import { @@ -486,10 +489,10 @@ export function createReviewHandlers(deps: CreateReviewHandlersDeps) { const fallbackReviewType = isCliAgentType(parent.reviewAgentType) ? parent.reviewAgentType : null; - const fallbackParentType = - parent.type === "claude" || parent.type === "opencode" - ? parent.type - : "codex"; + // A persona runs as its parent's own kind unless told otherwise. + const fallbackParentType = isCliAgentType(parent.type) + ? parent.type + : "codex"; const personaAgentType: (typeof CLI_AGENT_TYPES)[number] = opts.agentType ?? fallbackReviewType ?? fallbackParentType; if (!CLI_AGENT_TYPES.includes(personaAgentType)) { @@ -498,12 +501,20 @@ export function createReviewHandlers(deps: CreateReviewHandlersDeps) { ); } - const enabledAgentTypes = await getEnabledAgentTypes(pool); - if (!enabledAgentTypes.includes(personaAgentType)) { + // A persona runs as its parent's kind by default, so a harness parent + // launches a harness persona. That needs the offered list, which adds + // `dispatch` while the Dispatch Harness flag is on. + const offeredAgentTypes = await getOfferedAgentTypes(pool); + if (!offeredAgentTypes.includes(personaAgentType)) { throw new Error(`${personaAgentType} agents are disabled in settings.`); } - const personaModel = validateAgentModel(personaAgentType, opts.model); + // A harness persona inherits its parent's engine along with its kind: + // the engine is half the model id, so "no model" would otherwise mean + // Claude Code no matter what the parent runs on. + const personaModel = + validateAgentModel(personaAgentType, opts.model) ?? + inheritedHarnessModel(personaAgentType, parent); const parentCwd = parent.worktreePath ?? parent.cwd; let personaRoot: string; diff --git a/apps/server/src/shared/agent-models.ts b/apps/server/src/shared/agent-models.ts index f6a75dd86..ce349eeb3 100644 --- a/apps/server/src/shared/agent-models.ts +++ b/apps/server/src/shared/agent-models.ts @@ -1,6 +1,13 @@ +import { HARNESS_ENGINE_IDS, harnessEngineOf } from "@dispatch/shared"; + import { CLI_AGENT_TYPES, type AgentType } from "./agent-types.js"; -export type AgentModelOption = { id: string; label: string }; +export type AgentModelOption = { + id: string; + label: string; + /** Section header in grouped pickers (the provider), when known. */ + group?: string; +}; /** * Source-controlled model catalog for the launchers Dispatch supports. @@ -42,6 +49,67 @@ export const AGENT_MODEL_OPTIONS: Partial< { id: "haiku", label: "Haiku" }, { id: "fable", label: "Fable" }, ], + // Dispatch Harness ids are `engine/model`. The engine picks the ACP agent; + // the model half is what that engine calls it, or `default` for the + // engine's own default. The picker inside a running session reads the + // engine's live options; this list is for the create dialog. + dispatch: [ + { + id: "claude/default", + label: "Claude Code default", + group: "Claude Code", + }, + { id: "claude/claude-fable-5-1", label: "Fable 5.1", group: "Claude Code" }, + { id: "claude/claude-opus-5", label: "Opus 5", group: "Claude Code" }, + { id: "claude/claude-sonnet-5", label: "Sonnet 5", group: "Claude Code" }, + { + id: "claude/claude-haiku-4-5-20251001", + label: "Haiku 4.5", + group: "Claude Code", + }, + { id: "codex/default", label: "Codex default", group: "Codex" }, + { id: "codex/gpt-6-astra", label: "GPT-6 Astra", group: "Codex" }, + { id: "codex/gpt-5.6-sol", label: "GPT-5.6 Sol", group: "Codex" }, + { id: "codex/gpt-5.6-terra", label: "GPT-5.6 Terra", group: "Codex" }, + { id: "codex/gpt-5.6-luna", label: "GPT-5.6 Luna", group: "Codex" }, + { id: "codex/gpt-5.5", label: "GPT-5.5", group: "Codex" }, + { + id: "codex/gpt-5.3-codex-spark", + label: "GPT-5.3 Codex Spark (preview)", + group: "Codex", + }, + { + id: "gemini/default", + label: "Gemini CLI default (gemini-2.5-pro)", + group: "Gemini CLI", + }, + { + id: "gemini/gemini-3-pro-preview", + label: "Gemini 3 Pro (preview)", + group: "Gemini CLI", + }, + { + id: "gemini/gemini-3-flash-preview", + label: "Gemini 3 Flash (preview)", + group: "Gemini CLI", + }, + { + id: "gemini/gemini-3.5-flash", + label: "Gemini 3.5 Flash", + group: "Gemini CLI", + }, + { + id: "gemini/gemini-2.5-pro", + label: "Gemini 2.5 Pro", + group: "Gemini CLI", + }, + { + id: "gemini/gemini-2.5-flash", + label: "Gemini 2.5 Flash", + group: "Gemini CLI", + }, + { id: "opencode/default", label: "OpenCode default", group: "OpenCode" }, + ], }; export function getAgentModelOptions( @@ -104,12 +172,41 @@ export function describeAgentModelCatalog( return sentences.join(" "); } +/** + * The model half of a harness id. Slashes are allowed after the first one + * because OpenCode ids are `provider/model`, which is also the shape + * `HarnessSupervisor.setConfigOption` persists when a model is switched at + * runtime; a stricter rule here rejected the supervisor's own stored value + * the next time a job or template update path validated it. + */ +export const HARNESS_MODEL_HALF = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/; + +/** + * Split a harness model id the way `splitModelId` does at start time, so a + * typo is a 400 at create rather than "unknown engine" in the sidebar once + * the agent is already there. The engine catalog itself is the engine's, so + * the model half is only shape-checked. + */ +function validateHarnessModel(model: string): string { + const slash = model.indexOf("/"); + const engine = slash > 0 ? model.slice(0, slash) : ""; + const rest = slash > 0 ? model.slice(slash + 1) : ""; + const known = (HARNESS_ENGINE_IDS as readonly string[]).includes(engine); + if (!known || !HARNESS_MODEL_HALF.test(rest)) { + throw new Error( + `Model "${model}" is not a harness model id. Use engine/model, where engine is one of ${HARNESS_ENGINE_IDS.join(", ")}, for example codex/gpt-5.6-sol.` + ); + } + return model; +} + export function validateAgentModel( agentType: AgentType, model: string | undefined ): string | undefined { const normalizedModel = model?.trim() || undefined; if (normalizedModel === undefined) return undefined; + if (agentType === "dispatch") return validateHarnessModel(normalizedModel); if ( getAgentModelOptions(agentType).some( (option) => option.id === normalizedModel @@ -122,6 +219,27 @@ export function validateAgentModel( ); } +/** + * The model a child or persona of type `dispatch` runs with when the caller + * named none. + * + * A harness agent's engine is the first segment of its model id, so a child + * that runs as its parent's own kind has to carry the parent's engine as + * well: left to the supervisor's own default, a Codex- or Gemini-harness + * parent's reviewer runs on Claude Code, which is both the wrong reasoning + * and the wrong account to bill. Returns undefined for anything else, which + * leaves the CLI default in place. + */ +export function inheritedHarnessModel( + agentType: AgentType, + parent: { type?: string | null; model?: string | null } +): string | undefined { + if (agentType !== "dispatch" || parent.type !== "dispatch") return undefined; + const engine = harnessEngineOf(parent.model); + if (!engine) return undefined; + return parent.model || `${engine.id}/default`; +} + /** The agent-config fields every job/template create path defaults the same way. */ export type AgentConfigInput = { agentType?: T; diff --git a/apps/server/src/shared/agent-types.ts b/apps/server/src/shared/agent-types.ts index 2b0f58c23..045d1374a 100644 --- a/apps/server/src/shared/agent-types.ts +++ b/apps/server/src/shared/agent-types.ts @@ -11,11 +11,12 @@ import { AGENT_TYPES, CLI_AGENT_TYPES, + DEFAULT_ENABLED_AGENT_TYPES, type AgentType, type CliAgentType, } from "@dispatch/shared"; -export { AGENT_TYPES, CLI_AGENT_TYPES }; +export { AGENT_TYPES, CLI_AGENT_TYPES, DEFAULT_ENABLED_AGENT_TYPES }; export type { AgentType, CliAgentType }; export function isCliAgentType(value: unknown): value is CliAgentType { @@ -44,13 +45,22 @@ export function isPluginAgentType(value: unknown): value is PluginAgentType { ); } +/** + * `dispatch` is never a member. The Dispatch Harness has its own setting + * (`dispatch_harness_enabled`, see `dispatch-harness-settings.ts`) and + * `getOfferedAgentTypes` is what adds it back for the gates, so this list + * cannot be a second place the harness is turned on. A prerelease database + * can still hold it inside the stored JSON, which is why this drops it + * rather than trusting the writers. + */ export function sanitizeEnabledAgentTypes(value: unknown): AgentType[] { if (!Array.isArray(value)) { - return [...AGENT_TYPES]; + return [...DEFAULT_ENABLED_AGENT_TYPES]; } const unique = value .filter(isAgentType) + .filter((type) => type !== "dispatch") .filter((type, index, types) => types.indexOf(type) === index); - return unique.length > 0 ? unique : [...AGENT_TYPES]; + return unique.length > 0 ? unique : [...DEFAULT_ENABLED_AGENT_TYPES]; } diff --git a/apps/server/src/usage-budget-settings.ts b/apps/server/src/usage-budget-settings.ts new file mode 100644 index 000000000..9252458e6 --- /dev/null +++ b/apps/server/src/usage-budget-settings.ts @@ -0,0 +1,70 @@ +import type { Pool } from "pg"; +import { + HARNESS_BUDGET_ENGINE_IDS, + type HarnessEngineId, + type UsageBudgets, +} from "@dispatch/shared"; + +import { getSetting, setSetting } from "./db/settings.js"; + +/** + * Monthly spend budgets per engine, set in Settings. Only the usage + * dialog reads them, to draw a bar against the month's spend. Empty by + * default: no row, no bar. + */ +const USAGE_BUDGETS_KEY = "usage_budgets"; + +const ENGINE_IDS = new Set(HARNESS_BUDGET_ENGINE_IDS); + +/** A cost-reporting engine id: the only kind a dollar budget can name. */ +export function isUsageEngineId(id: unknown): id is HarnessEngineId { + return typeof id === "string" && ENGINE_IDS.has(id); +} + +/** + * The one definition of an acceptable budgets object: known engines only, + * each a positive finite number of USD (numeric strings are not numbers). + * Amounts keep two decimals. The route turns `ok: false` into a 400; the + * store reads back through the same rule so a bad row on disk is dropped. + */ +export function parseUsageBudgets( + input: unknown +): { ok: true; budgets: UsageBudgets } | { ok: false; error: string } { + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return { ok: false, error: "budgets must be an object." }; + } + const budgets: UsageBudgets = {}; + for (const [id, value] of Object.entries(input as Record)) { + if (!isUsageEngineId(id)) { + return { ok: false, error: `Unknown engine: ${id}.` }; + } + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return { + ok: false, + error: `Budget for ${id} must be a positive number of USD.`, + }; + } + budgets[id] = Math.round(value * 100) / 100; + } + return { ok: true, budgets }; +} + +export async function getUsageBudgets(pool: Pool): Promise { + const raw = await getSetting(pool, USAGE_BUDGETS_KEY); + if (!raw) return {}; + try { + const parsed = parseUsageBudgets(JSON.parse(raw)); + return parsed.ok ? parsed.budgets : {}; + } catch { + return {}; + } +} + +/** Store budgets that already passed {@link parseUsageBudgets}. */ +export async function setUsageBudgets( + pool: Pool, + budgets: UsageBudgets +): Promise { + await setSetting(pool, USAGE_BUDGETS_KEY, JSON.stringify(budgets)); + return budgets; +} diff --git a/apps/server/test/agent-models.test.ts b/apps/server/test/agent-models.test.ts index 226207c32..5643c3c97 100644 --- a/apps/server/test/agent-models.test.ts +++ b/apps/server/test/agent-models.test.ts @@ -4,6 +4,7 @@ import { AGENT_MODEL_OPTIONS, applyAgentConfigDefaults, describeAgentModelCatalog, + inheritedHarnessModel, resolveAgentModelForUpdate, validateAgentModel, } from "../src/shared/agent-models.js"; @@ -24,6 +25,59 @@ describe("validateAgentModel", () => { "not supported for claude" ); }); + + it("accepts a harness id whose model half carries slashes", () => { + // OpenCode model ids are provider/model, and setConfigOption stores + // exactly that shape, so a value the supervisor persists has to pass the + // validation the job and template update paths run over it. + expect( + validateAgentModel("dispatch", "opencode/anthropic/claude-sonnet-5") + ).toBe("opencode/anthropic/claude-sonnet-5"); + expect(validateAgentModel("dispatch", "codex/gpt-5.6-sol")).toBe( + "codex/gpt-5.6-sol" + ); + }); + + it("rejects a harness id whose engine is not one of the four", () => { + // Otherwise create returns 201 and the agent dies in setup with + // 'unknown engine', which is a worse place to learn about a typo. + for (const model of ["foo/bar", "claud/default", "default", "/default"]) { + expect(() => validateAgentModel("dispatch", model)).toThrow( + /claude, codex, gemini, opencode/ + ); + } + }); +}); + +describe("inheritedHarnessModel", () => { + it("carries the parent's engine to a harness child that named no model", () => { + expect( + inheritedHarnessModel("dispatch", { + type: "dispatch", + model: "codex/gpt-5.6-sol", + }) + ).toBe("codex/gpt-5.6-sol"); + // No stored model still names an engine: the default one. + expect( + inheritedHarnessModel("dispatch", { type: "dispatch", model: null }) + ).toBe("claude/default"); + }); + + it("inherits nothing from a parent of another kind, or for a child of another kind", () => { + expect( + inheritedHarnessModel("claude", { + type: "dispatch", + model: "codex/default", + }) + ).toBeUndefined(); + expect( + inheritedHarnessModel("dispatch", { type: "claude", model: "opus" }) + ).toBeUndefined(); + // A parent whose stored id names no engine has nothing to pass on. + expect( + inheritedHarnessModel("dispatch", { type: "dispatch", model: "/oops" }) + ).toBeUndefined(); + }); }); describe("describeAgentModelCatalog", () => { @@ -176,3 +230,13 @@ describe("resolveAgentModelForUpdate", () => { ).toBe("opus"); }); }); + +describe("dispatch catalog", () => { + it("lists engine-qualified ids for dispatch", () => { + const ids = (AGENT_MODEL_OPTIONS.dispatch ?? []).map((o) => o.id); + expect(ids).toContain("claude/claude-opus-5"); + expect(ids).toContain("codex/gpt-5.6-sol"); + expect(ids).not.toContain("openai/gpt-5.6-sol"); + for (const id of ids) expect(id).toMatch(/^[a-z0-9-]+\/[a-z0-9.-]+$/); + }); +}); diff --git a/apps/server/test/agent-prompts.test.ts b/apps/server/test/agent-prompts.test.ts index 8aa632557..2431ff407 100644 --- a/apps/server/test/agent-prompts.test.ts +++ b/apps/server/test/agent-prompts.test.ts @@ -23,11 +23,15 @@ function build(opts: { tmux?: boolean; quietMs?: number } = {}) { maxWaitMs: 1_000, }); const agentManager = { - getTerminalAccess: vi.fn(async () => + getPromptTarget: vi.fn(async () => opts.tmux === false - ? { mode: "inert" as const, message: "No pane." } - : { mode: "tmux" as const, sessionName: "sess" } + ? { kind: "inert" as const, message: "No pane." } + : { kind: "tmux" as const, sessionName: "sess" } ), + promptHarness: vi.fn(() => ({ + started: Promise.resolve(), + settled: Promise.resolve(), + })), }; const log = { debug: vi.fn(), warn: vi.fn(), info: vi.fn(), error: vi.fn() }; const injector = createPromptInjector( @@ -125,3 +129,80 @@ describe("injectAgentPrompt (wrapper)", () => { ); }); }); + +describe("enqueueAgentPrompt for harness agents", () => { + it("routes the prompt to the manager's harness turn instead of the pane", async () => { + const { enqueueAgentPrompt, agentManager } = build(); + agentManager.getPromptTarget.mockResolvedValue({ + kind: "harness" as const, + busy: false, + }); + const { held, delivery } = await enqueueAgentPrompt( + "agt_d", + "hello harness" + ); + expect(held).toBe(false); + await delivery; + expect(agentManager.promptHarness).toHaveBeenCalledWith( + "agt_d", + "hello harness" + ); + expect(sendCommand).not.toHaveBeenCalled(); + }); + + it("surfaces the manager's refusal when the harness is not running", async () => { + const { enqueueAgentPrompt, agentManager } = build(); + agentManager.getPromptTarget.mockRejectedValue( + new Error( + "The harness is not running for this agent; the prompt cannot be delivered." + ) + ); + await expect(enqueueAgentPrompt("agt_d", "x")).rejects.toThrow( + /harness is not running/ + ); + }); + + it("reports a prompt as held while a turn is already running", async () => { + const { enqueueAgentPrompt, agentManager } = build(); + agentManager.getPromptTarget.mockResolvedValue({ + kind: "harness" as const, + busy: true, + }); + let start: () => void = () => {}; + agentManager.promptHarness.mockReturnValue({ + started: new Promise((r) => { + start = r; + }), + settled: Promise.resolve(), + }); + const { held, delivery } = await enqueueAgentPrompt("agt_d", "queued"); + expect(held).toBe(true); + let delivered = false; + void delivery.then(() => { + delivered = true; + }); + await new Promise((r) => setTimeout(r, 0)); + expect(delivered).toBe(false); + start(); + await delivery; + }); + + it("logs a failed turn without rejecting the enqueue", async () => { + const { enqueueAgentPrompt, agentManager, log } = build(); + agentManager.getPromptTarget.mockResolvedValue({ + kind: "harness" as const, + busy: false, + }); + agentManager.promptHarness.mockReturnValue({ + started: Promise.resolve(), + settled: Promise.reject(new Error("turn exploded")), + }); + const { delivery } = await enqueueAgentPrompt("agt_d", "x"); + await delivery; + await new Promise((r) => setTimeout(r, 0)); + expect(log.warn).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "agt_d" }), + "harness turn failed" + ); + }); +}); diff --git a/apps/server/test/agent-type-settings.test.ts b/apps/server/test/agent-type-settings.test.ts index dd62b0917..6eb789caa 100644 --- a/apps/server/test/agent-type-settings.test.ts +++ b/apps/server/test/agent-type-settings.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "vitest"; import { - AGENT_TYPES, + DEFAULT_ENABLED_AGENT_TYPES, sanitizeEnabledAgentTypes, } from "../src/agent-type-settings.js"; describe("sanitizeEnabledAgentTypes", () => { it("returns defaults when the value is not an array", () => { - expect(sanitizeEnabledAgentTypes(undefined)).toEqual(AGENT_TYPES); + expect(sanitizeEnabledAgentTypes(undefined)).toEqual( + DEFAULT_ENABLED_AGENT_TYPES + ); }); it("filters unknown values and removes duplicates", () => { @@ -17,6 +19,25 @@ describe("sanitizeEnabledAgentTypes", () => { }); it("falls back to defaults when the array has no valid types", () => { - expect(sanitizeEnabledAgentTypes(["unknown"])).toEqual(AGENT_TYPES); + expect(sanitizeEnabledAgentTypes(["unknown"])).toEqual( + DEFAULT_ENABLED_AGENT_TYPES + ); + }); + + // The Dispatch Harness has its own setting (`dispatch_harness_enabled`), so + // this list is not where it is turned on. A prerelease database can still + // hold it inside the stored JSON; dropping it on read is what keeps every + // reader on one source for the harness. + it("drops the harness from a list that names it", () => { + expect(DEFAULT_ENABLED_AGENT_TYPES).not.toContain("dispatch"); + expect(sanitizeEnabledAgentTypes(["dispatch", "claude"])).toEqual([ + "claude", + ]); + }); + + it("falls back to the defaults for a list that names only the harness", () => { + expect(sanitizeEnabledAgentTypes(["dispatch"])).toEqual( + DEFAULT_ENABLED_AGENT_TYPES + ); }); }); diff --git a/apps/server/test/agents-routes.test.ts b/apps/server/test/agents-routes.test.ts index 7b3fb5aac..b5a726bee 100644 --- a/apps/server/test/agents-routes.test.ts +++ b/apps/server/test/agents-routes.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_HARNESS_MODEL } from "@dispatch/shared"; + import { useInjectApp } from "./helpers/inject-app.js"; const ctx = useInjectApp(); @@ -34,13 +36,23 @@ async function createAgent( return res.json().agent; } +/** Turn the Dispatch Harness flag on for one test. */ +async function enableDispatchHarness(): Promise { + await ctx.pool.query( + `INSERT INTO settings (key, value) + VALUES ('dispatch_harness_enabled', 'true') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value` + ); +} + beforeEach(async () => { await ctx.pool.query("DELETE FROM job_runs"); await ctx.pool.query("DELETE FROM jobs"); await ctx.pool.query("DELETE FROM agents"); await ctx.pool.query("DELETE FROM templates"); await ctx.pool.query( - "DELETE FROM settings WHERE key = 'enabled_agent_types'" + `DELETE FROM settings + WHERE key IN ('enabled_agent_types', 'dispatch_harness_enabled')` ); }); @@ -119,6 +131,34 @@ describe("POST /api/v1/agents (create)", () => { expect(res.json().error).toContain("not supported for claude"); }); + it("stores the default harness model for a dispatch agent created without one", async () => { + await enableDispatchHarness(); + // The model is the only place the engine is written down, and the usage + // report and the pane's login hint both read it from there. + const agent = await createAgent({ type: "dispatch" }); + expect(agent.model).toBe(DEFAULT_HARNESS_MODEL); + const chosen = await createAgent({ + type: "dispatch", + model: "codex/gpt-5.6-sol", + }); + expect(chosen.model).toBe("codex/gpt-5.6-sol"); + }); + + it("stores full access for a dispatch agent however it was asked for", async () => { + await enableDispatchHarness(); + // Every engine launches in its most permissive mode, so a stored false + // made the sidebar card read "Sandboxed" for the most permissive agent + // on the board. + expect((await createAgent({ type: "dispatch" })).fullAccess).toBe(true); + expect( + (await createAgent({ type: "dispatch", fullAccess: false })).fullAccess + ).toBe(true); + // Other kinds still do what they were asked. + expect( + (await createAgent({ type: "claude", fullAccess: false })).fullAccess + ).toBe(false); + }); + it("rejects missing cwd", async () => { const res = await authedInject("POST", "/api/v1/agents", {}); expect(res.statusCode).toBe(400); @@ -197,6 +237,24 @@ describe("POST /api/v1/agents (create)", () => { expect(res.json().error).toContain("disabled"); }); + // The harness is not a member of enabled_agent_types at all, so the create + // gate has to read the flag or a dispatch agent could never be created. + it("rejects a dispatch agent while the harness flag is off", async () => { + const res = await authedInject("POST", "/api/v1/agents", { + cwd: "/tmp", + useWorktree: false, + type: "dispatch", + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("dispatch agents are disabled in settings."); + }); + + it("accepts a dispatch agent once the harness flag is on", async () => { + await enableDispatchHarness(); + const agent = await createAgent({ type: "dispatch" }); + expect(agent.type).toBe("dispatch"); + }); + it("does not apply fullAccess for terminal agents", async () => { const agent = await createAgent({ type: "terminal", @@ -598,6 +656,31 @@ describe("PATCH /api/v1/agents/:id/review-agent-type", () => { expect(res.json().error).toContain("disabled"); }); + it("rejects the harness as a reviewer while its flag is off", async () => { + const agent = await createAgent({ type: "claude" }); + const res = await authedInject( + "PATCH", + `/api/v1/agents/${agent.id}/review-agent-type`, + { reviewAgentType: "dispatch" } + ); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("dispatch agents are disabled in settings."); + }); + + // The reviewer picker offers whatever the create dialog offers, so a saved + // dispatch reviewer has to be accepted or the picker offers a choice the + // server refuses. + it("accepts the harness as a reviewer once its flag is on", async () => { + await enableDispatchHarness(); + const agent = await createAgent({ type: "claude" }); + const res = await authedInject( + "PATCH", + `/api/v1/agents/${agent.id}/review-agent-type`, + { reviewAgentType: "dispatch" } + ); + expect(res.statusCode).toBe(200); + }); + it("returns 404 for non-existent agent", async () => { const res = await authedInject( "PATCH", diff --git a/apps/server/test/chat-feed.test.ts b/apps/server/test/chat-feed.test.ts index bdf0b7569..230090422 100644 --- a/apps/server/test/chat-feed.test.ts +++ b/apps/server/test/chat-feed.test.ts @@ -9,6 +9,7 @@ import { loadChatMessageEntry, toStatusEntry, } from "../src/chat/feed.js"; +import { listTurnEntries, loadLatestTurnEntry } from "../src/chat/turns.js"; import { ChatStore } from "../src/chat/store.js"; import { writeLatestEvent } from "../src/agents/events.js"; import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; @@ -43,6 +44,8 @@ beforeEach(async () => { await pool.query("DELETE FROM agent_messages"); await pool.query("DELETE FROM media"); await pool.query("DELETE FROM reviews"); + await pool.query("DELETE FROM pin_events"); + await pool.query("DELETE FROM agent_stream_events"); }); const at = (s: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, s)); @@ -585,6 +588,237 @@ describe("composeChatFeed", () => { }); }); +describe("turn entries in the feed", () => { + const settledTurn = (text: string, ended: number) => ({ + state: "settled", + prompt: { source: "system", text }, + stopReason: "end_turn", + endedAt: at(ended).toISOString(), + }); + + async function turnRow( + seq: number, + payload: Record, + when: number, + updated = when + ) { + await pool.query( + `INSERT INTO agent_stream_events + (agent_id, seq, kind, payload, created_at, updated_at) + VALUES ($1, $2, 'turn', $3::jsonb, $4, $5)`, + [A, seq, JSON.stringify(payload), at(when), at(updated)] + ); + } + + async function assistantRow( + seq: number, + text: string, + when: number, + updated = when + ) { + await pool.query( + `INSERT INTO agent_stream_events + (agent_id, seq, kind, payload, created_at, updated_at) + VALUES ($1, $2, 'assistant', $3::jsonb, $4, $5)`, + [ + A, + seq, + JSON.stringify({ text, streaming: false }), + at(when), + at(updated), + ] + ); + } + + it("orders a turn at its anchor among chat, review, pin, status and media rows", async () => { + await pool.query( + `INSERT INTO agent_events (agent_id, event_type, message, created_at) + VALUES ($1, 'working', 'reading', $2)`, + [A, at(1)] + ); + const m = await store.insert({ + agentId: A, + authorKind: "agent", + text: "hi", + }); + await pool.query( + `UPDATE agent_chat_messages SET created_at = $2 WHERE id = $1`, + [m.id, at(6)] + ); + await pool.query( + `INSERT INTO media (agent_id, file_name, source, size_bytes, description, created_at) + VALUES ($1, 'shot.png', 'screenshot', 10, 'a shot', $2)`, + [A, at(7)] + ); + await pool.query( + `INSERT INTO reviews (agent_id, reviewer_type, summary, created_at) + VALUES ($1, 'human', 'looks fine', $2)`, + [A, at(8)] + ); + await pool.query( + `INSERT INTO pin_events (agent_id, action, pin_id, label, created_at) + VALUES ($1, 'created', 'pin_1', 'Dev URL', $2)`, + [A, at(9)] + ); + await turnRow(1, settledTurn("do it", 4), 2, 4); + await assistantRow(2, "Done.", 3, 4); + + const feed = await composeChatFeed(store, A); + expect(feed.entries.map((e) => e.type)).toEqual([ + "status", + "turn", + "chat", + "media", + "review", + "pin", + ]); + const turn = feed.entries[1]; + if (turn.type !== "turn") throw new Error("expected a turn entry"); + expect(turn.at).toBe(at(2).toISOString()); + expect(turn.id).toMatch(/^turn:\d+$/); + expect(turn.agentId).toBe(A); + expect(turn.prompt.text).toBe("do it"); + expect(turn.result).toEqual({ text: "Done.", streaming: false }); + expect(turn.settled).toBe(true); + expect(turn.interrupted).toBe(false); + }); + + it("pages by anchor between two turns and repeats neither", async () => { + await turnRow(1, settledTurn("one", 2), 1, 2); + await assistantRow(2, "a", 2); + await turnRow(3, settledTurn("two", 4), 3, 4); + await assistantRow(4, "b", 4); + + const page1 = await composeChatFeed(store, A, { limit: 1 }); + expect(page1.hasMore).toBe(true); + expect(page1.entries.map((e) => e.type)).toEqual(["turn"]); + const page2 = await composeChatFeed(store, A, { + limit: 1, + cursor: decodeFeedCursor(page1.nextCursor!), + }); + expect(page2.hasMore).toBe(false); + const prompts = [...page2.entries, ...page1.entries].map((e) => + e.type === "turn" ? e.prompt.text : "" + ); + expect(prompts).toEqual(["one", "two"]); + const results = [...page2.entries, ...page1.entries].map((e) => + e.type === "turn" ? e.result?.text : "" + ); + expect(results).toEqual(["a", "b"]); + }); + + it("returns a turn whole even when its rows straddle the page limit", async () => { + await turnRow(1, settledTurn("old", 2), 1, 2); + await assistantRow(2, "old answer", 2); + await turnRow(3, settledTurn("big", 9), 3, 9); + for (let i = 0; i < 5; i += 1) { + await assistantRow(4 + i, `chunk ${i}`, 4 + i); + } + // Two entries either way: the limit counts entries, not stream rows. + const feed = await composeChatFeed(store, A, { limit: 2 }); + expect(feed.hasMore).toBe(false); + expect(feed.entries.map((e) => e.type)).toEqual(["turn", "turn"]); + const big = feed.entries[1]; + if (big.type !== "turn") throw new Error("expected a turn entry"); + expect(big.trace.steps.map((s) => s.label)).toEqual([ + "chunk 0", + "chunk 1", + "chunk 2", + "chunk 3", + ]); + expect(big.result?.text).toBe("chunk 4"); + }); + + it("carries an interrupted turn with its flag and final result", async () => { + await turnRow( + 1, + { + state: "settled", + prompt: { source: "system", text: "stopped" }, + stopReason: "cancelled", + endedAt: at(3).toISOString(), + }, + 1, + 3 + ); + await assistantRow(2, "half", 2); + const feed = await composeChatFeed(store, A); + const turn = feed.entries[0]; + if (turn.type !== "turn") throw new Error("expected a turn entry"); + expect(turn.interrupted).toBe(true); + expect(turn.trace.finalResult).toBe("interrupted"); + expect(turn.settled).toBe(true); + }); + + it("lists a question asked during a turn once, as a chat entry the turn references", async () => { + const question = await store.insert({ + agentId: A, + authorKind: "agent", + kind: "question", + text: "Which one?", + question: { options: [{ label: "A" }], allowFreeform: true }, + }); + await pool.query( + `UPDATE agent_chat_messages SET created_at = $2 WHERE id = $1`, + [question.id, at(3)] + ); + await turnRow(1, settledTurn("asking", 5), 1, 5); + const feed = await composeChatFeed(store, A); + expect(feed.entries.map((e) => e.type)).toEqual(["turn", "chat"]); + const turn = feed.entries[0]; + if (turn.type !== "turn") throw new Error("expected a turn entry"); + expect(turn.questions).toEqual([ + { messageId: question.id, answered: false }, + ]); + expect(feed.entries[1]).toMatchObject({ id: question.id }); + }); + + it("does not list the chat row a turn used as its prompt", async () => { + const prompt = await store.insert({ + agentId: A, + authorKind: "user", + text: "look please", + delivered: true, + }); + await pool.query( + `UPDATE agent_chat_messages SET created_at = $2 WHERE id = $1`, + [prompt.id, at(1)] + ); + const reply = await store.insert({ + agentId: A, + authorKind: "agent", + text: "an extra post", + }); + await pool.query( + `UPDATE agent_chat_messages SET created_at = $2 WHERE id = $1`, + [reply.id, at(6)] + ); + await turnRow( + 1, + { + state: "settled", + prompt: { source: "chat", chatMessageId: prompt.id }, + endedAt: at(4).toISOString(), + }, + 2, + 4 + ); + const feed = await composeChatFeed(store, A); + expect(feed.entries.map((e) => e.type)).toEqual(["turn", "chat"]); + const turn = feed.entries[0]; + if (turn.type !== "turn") throw new Error("expected a turn entry"); + expect(turn.prompt).toMatchObject({ + source: "chat", + text: "look please", + chatMessageId: prompt.id, + }); + expect(feed.entries[1]).toMatchObject({ id: reply.id }); + // The prompt row is not on this feed at all, so no page can bring it back. + expect(await loadChatMessageEntry(pool, A, prompt.id)).toBeNull(); + expect(await loadChatMessageEntry(pool, A, reply.id)).not.toBeNull(); + }); +}); + describe("feed entries as events carry them", () => { it("reads one message back exactly as the feed lists it", async () => { const { m1, m2 } = await seedAll(); @@ -632,3 +866,319 @@ describe("feed entries as events carry them", () => { ).toEqual(status); }); }); + +describe("listTurnEntries", () => { + /** + * `listTurnEntries` windows on `seq` and orders anchors on `created_at`, + * which the recorder keeps in step (seq is MAX(seq)+1 per agent, created_at + * is the insert's now()). These fixtures keep them in step too. + */ + async function stream( + rows: Array<{ + seq: number; + kind: string; + payload: Record; + at: number; + updated?: number; + key?: string; + }> + ) { + for (const r of rows) { + await pool.query( + `INSERT INTO agent_stream_events + (agent_id, seq, kind, key, payload, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)`, + [ + A, + r.seq, + r.kind, + r.key ?? null, + JSON.stringify(r.payload), + at(r.at), + at(r.updated ?? r.at), + ] + ); + } + } + + const settledTurn = (text: string, ended: number) => ({ + state: "settled", + prompt: { source: "system", text }, + stopReason: "end_turn", + endedAt: at(ended).toISOString(), + }); + + it("returns one entry per turn, newest first, anchored on the turn row", async () => { + await stream([ + { seq: 1, kind: "turn", payload: settledTurn("first", 3), at: 1 }, + { + seq: 2, + kind: "tool_call", + key: "c1", + payload: { + toolKind: "read", + title: "Read a", + status: "completed", + locations: [], + diff: null, + terminalOutput: null, + }, + at: 2, + updated: 2, + }, + { + seq: 3, + kind: "assistant", + payload: { text: "Done one.", streaming: false }, + at: 3, + }, + { seq: 4, kind: "turn", payload: settledTurn("second", 6), at: 4 }, + { + seq: 5, + kind: "assistant", + payload: { text: "Done two.", streaming: false }, + at: 5, + updated: 6, + }, + ]); + const page = await listTurnEntries(pool, A, null, 10); + expect(page.map((k) => k.entry.prompt.text)).toEqual(["second", "first"]); + const [second, first] = page; + expect(first.entry.at).toBe(at(1).toISOString()); + expect(first.entry.result).toEqual({ text: "Done one.", streaming: false }); + expect(first.entry.trace.steps.map((s) => s.label)).toEqual(["Read a"]); + expect(first.entry.settled).toBe(true); + expect(second.entry.updatedAt).toBe(at(6).toISOString()); + // The cursor key is the anchor row's own id and microsecond time. + expect(first.rawId).toMatch(/^\d+$/); + expect(first.atKey).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}$/); + }); + + it("keeps rows before the first turn row as one closed synthetic turn", async () => { + await stream([ + { + seq: 1, + kind: "assistant", + payload: { text: "older history", streaming: false }, + at: 1, + updated: 2, + }, + { seq: 2, kind: "turn", payload: settledTurn("after", 5), at: 4 }, + ]); + const page = await listTurnEntries(pool, A, null, 10); + expect(page).toHaveLength(2); + const pre = page[1]; + expect(pre.entry.id).toMatch(/^turn:pre:\d+$/); + expect(pre.entry.prompt.text).toBe("Earlier activity"); + expect(pre.entry.settled).toBe(true); + expect(pre.entry.at).toBe(at(1).toISOString()); + }); + + it("pages by anchor and never re-reads a newer turn's rows", async () => { + await stream([ + { seq: 1, kind: "turn", payload: settledTurn("one", 2), at: 1 }, + { + seq: 2, + kind: "assistant", + payload: { text: "a", streaming: false }, + at: 2, + }, + { seq: 3, kind: "turn", payload: settledTurn("two", 4), at: 3 }, + { + seq: 4, + kind: "assistant", + payload: { text: "b", streaming: false }, + at: 4, + }, + { seq: 5, kind: "turn", payload: settledTurn("three", 6), at: 5 }, + { + seq: 6, + kind: "assistant", + payload: { text: "c", streaming: false }, + at: 6, + }, + ]); + const newest = await listTurnEntries(pool, A, null, 2); + expect(newest.map((k) => k.entry.prompt.text)).toEqual(["three", "two"]); + expect(newest.map((k) => k.entry.result?.text)).toEqual(["c", "b"]); + const oldest = newest[newest.length - 1]; + const older = await listTurnEntries( + pool, + A, + { at: oldest.atKey, type: "turn", id: oldest.rawId }, + 2 + ); + expect(older.map((k) => k.entry.prompt.text)).toEqual(["one"]); + expect(older[0].entry.result?.text).toBe("a"); + }); + + it("carries an open turn with its live rail and growing text", async () => { + await stream([ + { + seq: 1, + kind: "turn", + payload: { state: "started", prompt: { source: "system", text: "go" } }, + at: 1, + }, + { + seq: 2, + kind: "tool_call", + key: "c1", + payload: { + toolKind: "execute", + title: "bash", + status: "in_progress", + locations: [], + diff: null, + terminalOutput: null, + }, + at: 2, + updated: 4, + }, + { + seq: 3, + kind: "assistant", + payload: { text: "half", streaming: true }, + at: 3, + updated: 5, + }, + ]); + const [live] = await listTurnEntries(pool, A, null, 10); + expect(live.entry.settled).toBe(false); + expect(live.entry.updatedAt).toBe(at(5).toISOString()); + expect(live.entry.result).toEqual({ text: "half", streaming: true }); + expect(live.entry.trace.steps[0]).toMatchObject({ + label: "bash", + status: "running", + }); + expect(live.entry.trace.endedAt).toBeUndefined(); + }); + + it("joins the chat prompt and references a question asked during the turn", async () => { + const prompt = await store.insert({ + agentId: A, + authorKind: "user", + text: "look please", + delivered: true, + }); + await pool.query( + `UPDATE agent_chat_messages SET created_at = $2 WHERE id = $1`, + [prompt.id, at(1)] + ); + const question = await store.insert({ + agentId: A, + authorKind: "agent", + kind: "question", + text: "Which one?", + question: { options: [{ label: "A" }], allowFreeform: true }, + }); + await pool.query( + `UPDATE agent_chat_messages SET created_at = $2 WHERE id = $1`, + [question.id, at(3)] + ); + await stream([ + { + seq: 1, + kind: "turn", + payload: { + state: "settled", + prompt: { source: "chat", chatMessageId: prompt.id }, + endedAt: at(4).toISOString(), + }, + at: 2, + updated: 4, + }, + ]); + const [entry] = await listTurnEntries(pool, A, null, 10); + expect(entry.entry.prompt).toMatchObject({ + source: "chat", + text: "look please", + chatMessageId: prompt.id, + }); + expect(entry.entry.questions).toEqual([ + { messageId: question.id, answered: false }, + ]); + }); + + it("hands back nothing for an agent with no stream rows", async () => { + expect(await listTurnEntries(pool, A, null, 10)).toEqual([]); + expect(await loadLatestTurnEntry(pool, A)).toBeNull(); + }); + + it("never invents a pre-turn entry on a page below the first turn", async () => { + // Task 2's review raised this as Task 3's design question: a window that + // began mid-turn would group the tail of a turn whose anchor sits on the + // next page into a synthetic "Earlier activity" turn, and those steps + // would render twice. listTurnEntries cannot do that, because every + // window starts at an anchor and an anchor is either a turn row or the + // agent's oldest row. This walks every page to prove it. + await stream([ + { + seq: 1, + kind: "assistant", + payload: { text: "history", streaming: false }, + at: 1, + }, + { seq: 2, kind: "turn", payload: settledTurn("one", 3), at: 2 }, + { + seq: 3, + kind: "assistant", + payload: { text: "a", streaming: false }, + at: 3, + }, + { seq: 4, kind: "turn", payload: settledTurn("two", 5), at: 4 }, + { + seq: 5, + kind: "assistant", + payload: { text: "b", streaming: false }, + at: 5, + }, + ]); + const seen: string[] = []; + let cursor: { at: string; type: "turn"; id: string } | null = null; + for (let page = 0; page < 5; page++) { + const entries = await listTurnEntries(pool, A, cursor, 1); + if (entries.length === 0) break; + for (const k of entries) seen.push(k.entry.id); + const oldest = entries[entries.length - 1]; + cursor = { at: oldest.atKey, type: "turn", id: oldest.rawId }; + } + // Three anchors, three entries, each exactly once: the two turns and the + // one genuine pre-turn group. + expect(seen).toHaveLength(3); + expect(new Set(seen).size).toBe(3); + expect(seen.filter((id) => id.startsWith("turn:pre:"))).toHaveLength(1); + }); + + it("loadLatestTurnEntry composes only the newest turn", async () => { + await stream([ + { seq: 1, kind: "turn", payload: settledTurn("old", 2), at: 1 }, + { + seq: 2, + kind: "assistant", + payload: { text: "old answer", streaming: false }, + at: 2, + }, + { + seq: 3, + kind: "turn", + payload: { + state: "started", + prompt: { source: "system", text: "new" }, + }, + at: 3, + }, + { + seq: 4, + kind: "assistant", + payload: { text: "new answer", streaming: true }, + at: 4, + updated: 5, + }, + ]); + const entry = await loadLatestTurnEntry(pool, A); + expect(entry?.prompt.text).toBe("new"); + expect(entry?.result?.text).toBe("new answer"); + expect(entry?.settled).toBe(false); + }); +}); diff --git a/apps/server/test/chat-routes.test.ts b/apps/server/test/chat-routes.test.ts index 4c3699a0f..be13186c6 100644 --- a/apps/server/test/chat-routes.test.ts +++ b/apps/server/test/chat-routes.test.ts @@ -449,6 +449,42 @@ describe("POST /api/v1/agents/:id/chat/read", () => { expect(all.json()).toEqual({ unreadCount: 0 }); }); + it("clears a settled turn from the count on the bounded read the pane sends", async () => { + // The pane sends upTo = the newest agent message it holds, which is + // non-null the moment the agent has ever posted. Gating the watermark on + // an unbounded read meant it never moved and the badge never cleared. + const question = await store.insert({ + agentId, + authorKind: "agent", + text: "ship it?", + }); + await ctx.pool.query( + `INSERT INTO agent_stream_events + (agent_id, seq, kind, payload, created_at, updated_at) + VALUES ($1, 900, 'turn', $2::jsonb, NOW(), NOW())`, + [ + agentId, + JSON.stringify({ + state: "settled", + prompt: { source: "system", text: "go" }, + }), + ] + ); + // One unread chat row plus one settled turn. + const before = await authedInject( + "GET", + `/api/v1/agents/${agentId}/chat?limit=10` + ); + expect((before.json() as { unreadCount: number }).unreadCount).toBe(2); + + const read = await authedInject( + "POST", + `/api/v1/agents/${agentId}/chat/read`, + { upTo: question.id } + ); + expect(read.json()).toEqual({ unreadCount: 0 }); + }); + it("400s a present-but-invalid upTo and treats null as omitted", async () => { await store.insert({ agentId, authorKind: "agent", text: "1" }); for (const upTo of ["nope", 5, {}]) { @@ -581,6 +617,7 @@ describe("chat routes with a deliverable terminal", () => { publishUiEvent: (event) => published.push(event), getAgent: async (id) => ({ id, + type: "claude", mediaDir: null, pins: [{ id: "pin_1", label: "PR", value: "https://gh/1" }] as never, }), diff --git a/apps/server/test/chat-service.test.ts b/apps/server/test/chat-service.test.ts index bbcff1f37..ef46684a9 100644 --- a/apps/server/test/chat-service.test.ts +++ b/apps/server/test/chat-service.test.ts @@ -49,7 +49,9 @@ beforeAll(async () => { pool, publishUiEvent: (event) => published.push(event), getAgent: async (id) => - id === A ? { id, mediaDir: null, pins: PINS as never } : null, + id === A + ? { id, type: "claude", mediaDir: null, pins: PINS as never } + : null, mediaRoot: "/media-root", }); }); @@ -620,6 +622,7 @@ describe("ChatService user workflows", () => { /** Resolve to release deliveries; absent = deliver immediately. */ gate?: Promise; fail?: boolean; + agentType?: "claude" | "dispatch"; } = {} ) { const events: unknown[] = []; @@ -629,7 +632,12 @@ describe("ChatService user workflows", () => { publishUiEvent: (event) => events.push(event), getAgent: async (id) => id === A - ? { id, mediaDir: "/custom/media", pins: PINS as never } + ? { + id, + type: opts.agentType ?? "claude", + mediaDir: "/custom/media", + pins: PINS as never, + } : null, mediaRoot: "/media-root", delivery: { @@ -765,6 +773,18 @@ describe("ChatService user workflows", () => { ); }); + it("tells a stream-driven (harness) agent its replies land in Chat by themselves", async () => { + const { svc, injected } = build({ agentType: "dispatch" }); + const res = await svc.sendUserMessage(A, "hello harness"); + await settled(svc, res.message.id); + expect(injected[0].text).toContain("--- DISPATCH CHAT"); + expect(injected[0].text).toContain("hello harness"); + expect(injected[0].text).not.toContain("The user only sees Chat"); + expect(injected[0].text).toContain( + `Only a question with options needs dispatch_chat_post (replyTo: "${res.message.id}")` + ); + }); + it("sendUserMessage accepts blank text with an attachment and lists only the attachments", async () => { const { svc, injected } = build(); const res = await svc.sendUserMessage(A, "", [ @@ -981,6 +1001,24 @@ describe("ChatService user workflows", () => { expect(injected).toHaveLength(0); }); + it("redeliverPending abandons the queue when the resumed harness has no pane", async () => { + // A Dispatch Harness agent that came back on an inert runtime: nothing + // to inject into, and the boot sweep skipped it as a running harness. + const { svc, events } = build({ + agentType: "dispatch", + access: async () => ({ mode: "inert", message: "No pane." }), + }); + const pending = await svc.store.insert({ + agentId: A, + authorKind: "user", + text: "waiting since before the restart", + delivered: null, + }); + expect(await svc.redeliverPending(A)).toBe(0); + expect((await svc.store.getById(pending.id))?.delivered).toBe(false); + expect(events).toEqual([{ type: "chat.changed", agentId: A }]); + }); + it("recoverPendingDeliveries sweeps pending user rows and announces each feed", async () => { const { svc, events } = build(); const pending = await svc.store.insert({ @@ -1030,6 +1068,114 @@ describe("ChatService user workflows", () => { }); }); +describe("ChatService.launchPromptFor", () => { + it("delivers the launch post's delivery text when one was stored", async () => { + const prepared = await service.prepareLaunchContext({ + id: "8a4f9e60-aaaa-4222-8333-444455556666", + agentId: A, + text: "Summarise the README", + deliveryText: + "You were launched by agent agt_parent.\n\nSummarise the README", + }); + await prepared!.record(); + const first = await service.launchPromptFor(A); + expect(first).toContain("You were launched by agent agt_parent."); + expect(first).toContain("8a4f9e60-aaaa-4222-8333-444455556666"); + // Chat shows the prompt as written. + const rows = await pool.query<{ text: string; delivery_text: string }>( + "SELECT text, delivery_text FROM agent_chat_messages WHERE agent_id = $1", + [A] + ); + expect(rows.rows[0].text).toBe("Summarise the README"); + expect(rows.rows[0].delivery_text).toContain("You were launched"); + }); + + it("falls back to the post text", async () => { + const prepared = await service.prepareLaunchContext({ + agentId: A, + text: "Just this", + }); + await prepared!.record(); + expect(await service.launchPromptFor(A)).toContain("Just this"); + }); +}); + +describe("publishTurnEntry", () => { + const at = (s: number) => new Date(Date.UTC(2026, 8, 8, 10, 0, s)); + + it("publishes the newest turn as one feed entry", async () => { + await pool.query("DELETE FROM agent_stream_events"); + await pool.query( + `INSERT INTO agent_stream_events + (agent_id, seq, kind, payload, created_at, updated_at) + VALUES ($1, 1, 'turn', $2::jsonb, $3, $4), + ($1, 2, 'assistant', $5::jsonb, $4, $6)`, + [ + A, + JSON.stringify({ + state: "started", + prompt: { source: "system", text: "go" }, + }), + at(1), + at(2), + JSON.stringify({ text: "working on it", streaming: true }), + at(5), + ] + ); + published.length = 0; + await service.publishTurnEntry(A); + expect(published).toHaveLength(1); + expect(published[0]).toMatchObject({ + type: "chat.entry", + agentId: A, + entry: { + type: "turn", + agentId: A, + at: at(1).toISOString(), + updatedAt: at(5).toISOString(), + settled: false, + result: { text: "working on it", streaming: true }, + }, + }); + }); + + it("publishes nothing for an agent with no stream rows", async () => { + await pool.query("DELETE FROM agent_stream_events"); + published.length = 0; + await service.publishTurnEntry(A); + expect(published).toEqual([]); + }); + + it("runs one compose at a time and collapses the rest into one re-run", async () => { + await pool.query("DELETE FROM agent_stream_events"); + await pool.query( + `INSERT INTO agent_stream_events + (agent_id, seq, kind, payload, created_at, updated_at) + VALUES ($1, 1, 'turn', $2::jsonb, $3, $4)`, + [ + A, + JSON.stringify({ + state: "started", + prompt: { source: "system", text: "go" }, + }), + at(1), + at(2), + ] + ); + published.length = 0; + // The recorder flushes about ten times a second; each of these is one + // flush arriving while the compose before it is still reading. + await Promise.all([ + service.publishTurnEntry(A), + service.publishTurnEntry(A), + service.publishTurnEntry(A), + service.publishTurnEntry(A), + ]); + // The first call composes; the other three collapse into one re-run, + // because only the newest state is worth sending. + expect(published).toHaveLength(2); + }); +}); describe("ChatService reactions", () => { type Injected = { agentId: string; text: string }; diff --git a/apps/server/test/chat-store.test.ts b/apps/server/test/chat-store.test.ts index 5172888a2..d19b2e382 100644 --- a/apps/server/test/chat-store.test.ts +++ b/apps/server/test/chat-store.test.ts @@ -188,6 +188,58 @@ describe("ChatStore", () => { expect(await store.countUnread(B)).toBe(1); }); + it("counts a settled turn as unread until the feed is marked read", async () => { + // A harness agent's answer is a turn, never a chat row, so this is the + // only thing its badge can count. + await pool.query("DELETE FROM agent_stream_events"); + await store.markRead(A); + await store.markFeedRead(A); + expect(await store.countUnread(A)).toBe(0); + + const settled = (seq: number) => + pool.query( + `INSERT INTO agent_stream_events + (agent_id, seq, kind, payload, created_at, updated_at) + VALUES ($1, $2, 'turn', $3::jsonb, NOW(), NOW())`, + [ + A, + seq, + JSON.stringify({ + state: "settled", + prompt: { source: "system", text: "go" }, + }), + ] + ); + try { + await settled(1); + await settled(2); + expect(await store.countUnread(A)).toBe(2); + // An open turn is not news yet. + await pool.query( + `INSERT INTO agent_stream_events + (agent_id, seq, kind, payload, created_at, updated_at) + VALUES ($1, 3, 'turn', $2::jsonb, NOW(), NOW())`, + [ + A, + JSON.stringify({ + state: "started", + prompt: { source: "system", text: "go" }, + }), + ] + ); + expect(await store.countUnread(A)).toBe(2); + + await store.markFeedRead(A); + expect(await store.countUnread(A)).toBe(0); + const summary = await store.unreadSummary(); + expect(summary.agents[A]?.unread ?? 0).toBe(0); + } finally { + // A failure here would otherwise leave unread turns behind and count + // against the next case's agent. + await pool.query("DELETE FROM agent_stream_events"); + } + }); + it("treats malformed ids as not found instead of erroring", async () => { expect(await store.getById("nope")).toBeNull(); expect(await store.update("nope", { text: "x" })).toBeNull(); @@ -310,4 +362,18 @@ describe("ChatStore.sweepPendingDeliveries", () => { // Idempotent: a second sweep finds nothing. expect(await store.sweepPendingDeliveries()).toEqual([]); }); + + it("finds the launch-context post for an agent", async () => { + expect(await store.getLaunchPost(A)).toBeNull(); + const posted = await store.insert({ + agentId: A, + authorKind: "user", + kind: "reply", + text: "launch text", + attachments: [], + delivered: true, + origin: "launch", + }); + expect((await store.getLaunchPost(A))?.id).toBe(posted.id); + }); }); diff --git a/apps/server/test/chat-turns.test.ts b/apps/server/test/chat-turns.test.ts new file mode 100644 index 000000000..453c0fa65 --- /dev/null +++ b/apps/server/test/chat-turns.test.ts @@ -0,0 +1,938 @@ +import { describe, expect, it } from "vitest"; +import type { ChatMessage } from "@dispatch/shared"; + +import { + assembleTurns, + groupTurnRows, + loadQueued, + toTurnEntry, + type TurnSourceRow, +} from "../src/chat/turns.js"; + +let seq = 0; +const at = (s: number) => new Date(Date.UTC(2026, 8, 4, 10, 0, s)); +function row( + kind: TurnSourceRow["kind"], + payload: Record, + s: number, + settledAt?: number, + key: string | null = null +): TurnSourceRow { + seq += 1; + return { + id: seq, + seq, + kind, + key, + payload, + createdAt: at(s), + updatedAt: at(settledAt ?? s), + }; +} +const chatMsg = (id: string, text: string, origin?: "launch"): ChatMessage => ({ + id, + agentId: "a", + authorKind: "user", + kind: "reply", + text, + replyTo: null, + question: null, + answer: null, + attachments: [], + delivered: true, + readAt: null, + ...(origin ? { origin } : {}), + createdAt: at(0).toISOString(), + updatedAt: at(0).toISOString(), +}); + +describe("assembleTurns", () => { + it("cuts the stream into turns with prompt, steps, and result", () => { + seq = 0; + const rows = [ + row( + "turn", + { + state: "settled", + prompt: { source: "chat", chatMessageId: "m1" }, + stopReason: "end_turn", + endedAt: at(9).toISOString(), + }, + 0, + 9 + ), + row("assistant", { text: "Let me look.", streaming: false }, 1), + row( + "tool_call", + { + toolKind: "other", + title: "mcp__dispatch__dispatch_event", + status: "completed", + locations: [], + diff: null, + terminalOutput: "ok", + }, + 2, + 2 + ), + row( + "tool_call", + { + toolKind: "execute", + title: "bash", + status: "completed", + locations: [], + diff: null, + terminalOutput: "a\nb\n", + }, + 3, + 5 + ), + row("thought", { text: "reasoning" }, 6), + row("assistant", { text: "Done: two files.", streaming: false }, 8), + row( + "turn", + { state: "started", prompt: { source: "system", text: "again" } }, + 10 + ), + row("assistant", { text: "Work", streaming: true }, 11), + ]; + const turns = assembleTurns( + rows, + new Map([["m1", chatMsg("m1", "look please")]]) + ); + expect(turns).toHaveLength(2); + const [first, second] = turns; + expect(first.prompt).toMatchObject({ + source: "chat", + text: "look please", + chatMessageId: "m1", + }); + expect(first.trace.finalResult).toBe("ok"); + expect(first.trace.steps.map((s) => [s.kind, s.label, s.status])).toEqual([ + ["note", "Let me look.", "ok"], + ["execute", "bash", "ok"], + ["think", "thinking", "ok"], + ]); + expect(first.trace.steps[1].durMs).toBe(2000); + expect(first.result).toEqual({ + text: "Done: two files.", + streaming: false, + }); + expect(second.prompt).toEqual({ + source: "system", + text: "again", + attachments: [], + }); + expect(second.trace.endedAt).toBeUndefined(); + expect(second.result).toEqual({ text: "Work", streaming: true }); + }); + + it("marks a launch post and a cross-agent message by source", () => { + seq = 0; + const rows = [ + row( + "turn", + { + state: "settled", + prompt: { source: "chat", chatMessageId: "L" }, + endedAt: at(1).toISOString(), + }, + 0, + 1 + ), + row( + "turn", + { + state: "settled", + prompt: { + source: "agent", + senderId: "agt_x", + senderName: "Reviewer", + text: "hi", + }, + endedAt: at(3).toISOString(), + }, + 2, + 3 + ), + ]; + const turns = assembleTurns( + rows, + new Map([["L", chatMsg("L", "launch text", "launch")]]) + ); + expect(turns[0].prompt.source).toBe("launch"); + expect(turns[1].prompt).toMatchObject({ + source: "agent", + senderName: "Reviewer", + text: "hi", + }); + }); + + it("folds rows before the first turn row into one synthetic turn and carries a settle error", () => { + seq = 0; + const rows = [ + row( + "tool_call", + { + toolKind: "read", + title: "read", + status: "failed", + locations: [{ path: "x" }], + diff: null, + terminalOutput: null, + }, + 0, + 1 + ), + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "p" }, + error: "no API key", + endedAt: at(3).toISOString(), + }, + 2, + 3 + ), + row("status", { message: "no API key" }, 3), + ]; + const turns = assembleTurns(rows, new Map()); + expect(turns[0].prompt).toEqual({ + source: "system", + text: "Earlier activity", + attachments: [], + }); + expect(turns[0].trace.steps[0]).toMatchObject({ + kind: "read", + status: "error", + }); + expect(turns[0].trace.finalResult).toBe("ok"); + expect(turns[1].error).toBe("no API key"); + expect(turns[1].trace.finalResult).toBe("error"); + }); + + it("reads a cancelled turn as interrupted, not complete", () => { + const rows: TurnSourceRow[] = [ + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "p" }, + stopReason: "cancelled", + endedAt: at(2).toISOString(), + }, + 0, + 2 + ), + row("assistant", { text: "half", streaming: false }, 1), + ]; + const turns = assembleTurns(rows, new Map()); + expect(turns[0].trace.finalResult).toBe("interrupted"); + expect(turns[0].error).toBeUndefined(); + expect(turns[0].result?.text).toBe("half"); + }); + + it("nests a subagent's steps under the parent Task step", () => { + seq = 0; + const rows = [ + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "go" }, + stopReason: "end_turn", + endedAt: at(9).toISOString(), + }, + 0, + 9 + ), + row( + "tool_call", + { + toolKind: "other", + title: "Task", + status: "completed", + locations: [], + diff: null, + terminalOutput: null, + }, + 1, + 8, + "task_1" + ), + row( + "tool_call", + { + toolKind: "read", + title: "Read", + status: "completed", + locations: [{ path: "a.ts" }], + diff: null, + terminalOutput: null, + parentToolCallId: "task_1", + }, + 2, + 3, + "child_1" + ), + row( + "tool_call", + { + toolKind: "execute", + title: "bash", + status: "completed", + locations: [], + diff: null, + terminalOutput: "ok", + parentToolCallId: "task_1", + }, + 4, + 5, + "child_2" + ), + row( + "tool_call", + { + toolKind: "edit", + title: "Edit", + status: "completed", + locations: [], + diff: null, + terminalOutput: null, + }, + 6, + 7, + "top_2" + ), + ]; + const [turn] = assembleTurns(rows, new Map()); + expect(turn.trace.steps.map((s) => s.label)).toEqual(["Task", "Edit"]); + expect(turn.trace.steps[0].children?.map((s) => s.label)).toEqual([ + "Read", + "bash", + ]); + expect(turn.trace.steps[0].children?.[0].detail.parentToolCallId).toBe( + "task_1" + ); + expect(turn.trace.steps[1].children).toBeUndefined(); + }); + + it("keeps a child whose parent is not in the turn at the top level", () => { + seq = 0; + const rows = [ + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "go" }, + stopReason: "end_turn", + endedAt: at(2).toISOString(), + }, + 0, + 2 + ), + row( + "tool_call", + { + toolKind: "read", + title: "Read", + status: "completed", + locations: [], + diff: null, + terminalOutput: null, + parentToolCallId: "gone", + }, + 1, + 1, + "orphan" + ), + ]; + const [turn] = assembleTurns(rows, new Map()); + expect(turn.trace.steps.map((s) => s.label)).toEqual(["Read"]); + }); + + it("carries the newest plan and the turn's usage", () => { + seq = 0; + const rows = [ + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "go" }, + stopReason: "end_turn", + endedAt: at(5).toISOString(), + usage: { + used: 4200, + size: 200000, + cost: { amount: 0.5, currency: "USD" }, + }, + }, + 0, + 5 + ), + row( + "plan", + { + entries: [ + { content: "a", status: "completed", priority: "high" }, + { content: "b", status: "in_progress", priority: "low" }, + ], + }, + 1, + 4, + "plan:1" + ), + row("assistant", { text: "done", streaming: false }, 2), + ]; + const [turn] = assembleTurns(rows, new Map()); + expect(turn.plan).toEqual([ + { content: "a", status: "completed", priority: "high" }, + { content: "b", status: "in_progress", priority: "low" }, + ]); + expect(turn.usage).toEqual({ used: 4200, size: 200000, costUsd: 0.5 }); + expect(turn.trace.steps).toEqual([]); + }); + + it("reports no cost for a cost the engine gave in another currency", () => { + seq = 0; + const rows = [ + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "go" }, + stopReason: "end_turn", + endedAt: at(1).toISOString(), + usage: { + used: 10, + size: 100, + cost: { amount: 2.5, currency: "EUR" }, + }, + }, + 0, + 1 + ), + ]; + const [turn] = assembleTurns(rows, new Map()); + expect(turn.usage).toEqual({ used: 10, size: 100, costUsd: null }); + }); + + it("reports usage without cost as costUsd null", () => { + seq = 0; + const rows = [ + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "go" }, + stopReason: "end_turn", + endedAt: at(1).toISOString(), + usage: { used: 10, size: 100 }, + }, + 0, + 1 + ), + ]; + const [turn] = assembleTurns(rows, new Map()); + expect(turn.usage).toEqual({ used: 10, size: 100, costUsd: null }); + }); +}); + +describe("assembleTurns with agent questions", () => { + it("carries a question on the turn it was asked in, with its answer state", () => { + const rows = [ + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "first" }, + endedAt: at(5).toISOString(), + }, + 0, + 5 + ), + row("assistant", { text: "Which one?", streaming: false }, 2), + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "second" }, + endedAt: at(12).toISOString(), + }, + 10, + 12 + ), + ]; + const question = { + id: "q1", + agentId: "agt_1", + authorKind: "agent" as const, + kind: "question" as const, + text: "Scope choice: fix the preview alone, or bundle it?", + replyTo: null, + question: { + options: [ + { label: "Preview only" }, + { label: "Bundle", value: "bundle" }, + ], + allowFreeform: true, + }, + answer: null, + attachments: [], + delivered: null, + readAt: null, + createdAt: at(3).toISOString(), + updatedAt: at(3).toISOString(), + }; + const turns = assembleTurns(rows, new Map(), [question as never]); + expect(turns[0].questions).toEqual([ + { + id: "q1", + text: "Scope choice: fix the preview alone, or bundle it?", + options: [ + { label: "Preview only" }, + { label: "Bundle", value: "bundle" }, + ], + allowFreeform: true, + answer: null, + createdAt: at(3).toISOString(), + }, + ]); + expect(turns[1].questions).toBeUndefined(); + }); +}); + +describe("assembleTurns labels", () => { + it("labels a turn with the agent's last terminal dispatch_event message", () => { + const rows = [ + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "go" }, + endedAt: at(9).toISOString(), + }, + 0, + 9 + ), + row( + "tool_call", + { + title: "mcp__dispatch__dispatch_event", + toolKind: "other", + status: "completed", + locations: [], + diff: null, + terminalOutput: "ok", + input: { type: "working", message: "Reading README.md" }, + }, + 1 + ), + row( + "tool_call", + { + title: "read", + toolKind: "read", + status: "completed", + locations: [], + diff: null, + terminalOutput: "x", + }, + 2 + ), + row( + "tool_call", + { + title: "mcp__dispatch__dispatch_event", + toolKind: "other", + status: "completed", + locations: [], + diff: null, + terminalOutput: "ok", + input: { type: "idle", message: "Answered README question" }, + }, + 3 + ), + ]; + const turns = assembleTurns(rows, new Map()); + expect(turns[0].label).toBe("Answered README question"); + // The status calls themselves stay out of the steps. + expect(turns[0].trace.steps.map((s) => s.kind)).toEqual(["read"]); + }); + + it("falls back to the last working message, and to nothing", () => { + const working = [ + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "go" }, + endedAt: at(2).toISOString(), + }, + 0, + 2 + ), + row( + "tool_call", + { + title: "mcp__dispatch__dispatch_event", + toolKind: "other", + status: "completed", + locations: [], + diff: null, + terminalOutput: "ok", + input: { type: "working", message: "Checking the tree" }, + }, + 1 + ), + ]; + expect(assembleTurns(working, new Map())[0].label).toBe( + "Checking the tree" + ); + const none = [ + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "go" }, + endedAt: at(1).toISOString(), + }, + 0, + 1 + ), + ]; + expect(assembleTurns(none, new Map())[0].label).toBeUndefined(); + }); +}); + +describe("loadQueued", () => { + it("joins chat text onto queued chat prompts and passes the rest through", async () => { + // A real chat id: the read casts these to uuid, so an ill-formed one is + // dropped before the query rather than handed to Postgres. + const CHAT_ID = "fae1f052-5d66-4039-9bde-35ac8166695d"; + const message = chatMsg(CHAT_ID, "second thoughts"); + const db = { + query: async (_sql: string, params?: unknown[]) => { + expect(params?.[0]).toEqual([CHAT_ID]); + return { + rows: [ + { + id: message.id, + agent_id: "a", + author_kind: "user", + kind: "reply", + text: message.text, + reply_to: null, + question: null, + answer: null, + attachments: [], + delivered: null, + delivery_text: null, + read_at: null, + origin: null, + created_at: at(0), + updated_at: at(0), + }, + ], + rowCount: 1, + }; + }, + }; + const queued = await loadQueued(db as never, [ + { + id: CHAT_ID, + source: { source: "chat", chatMessageId: CHAT_ID }, + createdAt: at(1).toISOString(), + }, + { + id: "q_1", + source: { + source: "agent", + senderId: "agt_r", + senderName: "Reviewer", + text: "also this", + }, + createdAt: at(2).toISOString(), + }, + ]); + expect(queued).toEqual([ + { + id: CHAT_ID, + source: "chat", + text: "second thoughts", + chatMessageId: CHAT_ID, + attachments: [], + createdAt: at(1).toISOString(), + }, + { + id: "q_1", + source: "agent", + text: "also this", + senderAgentId: "agt_r", + senderName: "Reviewer", + attachments: [], + createdAt: at(2).toISOString(), + }, + ]); + }); + + it("skips the chat read when every queued chat id is ill-formed", async () => { + // Otherwise the `::uuid[]` cast throws and this agent's turns read 500 + // from then on, because the offending prompt row is persisted. + const db = { + query: async () => { + throw new Error("should not query"); + }, + }; + expect( + await loadQueued(db as never, [ + { + id: "0".repeat(36), + source: { source: "chat", chatMessageId: "0".repeat(36) }, + createdAt: at(1).toISOString(), + }, + ]) + ).toEqual([ + { + id: "0".repeat(36), + source: "chat", + text: "", + chatMessageId: "0".repeat(36), + attachments: [], + createdAt: at(1).toISOString(), + }, + ]); + }); + + it("skips the chat read when nothing queued came from chat", async () => { + const db = { + query: async () => { + throw new Error("should not query"); + }, + }; + expect(await loadQueued(db as never, [])).toEqual([]); + }); +}); + +describe("assembleTurns thinking", () => { + it("marks the newest thought of a live turn as running, and times settled ones", () => { + seq = 0; + const live = assembleTurns( + [ + row( + "turn", + { state: "started", prompt: { source: "system", text: "go" } }, + 0 + ), + row( + "tool_call", + { toolKind: "read", title: "read", status: "completed" }, + 1, + 2 + ), + row("thought", { text: "" }, 3, 5), + ], + new Map() + ); + const steps = live[0].trace.steps; + expect(steps.map((s) => [s.kind, s.status])).toEqual([ + ["read", "ok"], + ["think", "running"], + ]); + expect(steps[1].endedAt).toBeUndefined(); + + seq = 0; + const settled = assembleTurns( + [ + row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "go" }, + endedAt: at(9).toISOString(), + }, + 0, + 9 + ), + row("thought", { text: "hmm" }, 3, 7), + row("assistant", { text: "done", streaming: false }, 8), + ], + new Map() + ); + const think = settled[0].trace.steps[0]; + expect(think).toMatchObject({ kind: "think", status: "ok", durMs: 4000 }); + }); +}); + +describe("groupTurnRows", () => { + it("cuts at each turn row and keeps rows before the first one in their own group", () => { + seq = 0; + const early = row("assistant", { text: "before", streaming: false }, 0); + const first = row( + "turn", + { state: "settled", prompt: { source: "system", text: "one" } }, + 1, + 3 + ); + const inFirst = row("assistant", { text: "a", streaming: false }, 2); + const second = row( + "turn", + { state: "started", prompt: { source: "system", text: "two" } }, + 4 + ); + const groups = groupTurnRows([early, first, inFirst, second]); + expect(groups).toHaveLength(3); + expect(groups[0]).toEqual({ turn: null, rows: [early] }); + expect(groups[1]).toEqual({ turn: first, rows: [inFirst] }); + expect(groups[2]).toEqual({ turn: second, rows: [] }); + }); + + it("indexes one to one with assembleTurns over the same rows", () => { + seq = 0; + const rows = [ + row("thought", { text: "hmm" }, 0), + row( + "turn", + { state: "settled", prompt: { source: "system", text: "p" } }, + 1, + 2 + ), + ]; + // Comparing the two lengths cannot fail: assembleTurns maps over + // groupTurnRows, so the counts agree for every input. What toTurnEntry + // actually relies on is the pairing, since it takes `id` from the turn + // and `at`/`updatedAt`/`settled` from the group at the same index. + const groups = groupTurnRows(rows); + const turns = assembleTurns(rows, new Map()); + expect(turns).toHaveLength(groups.length); + expect(turns.map((t) => t.id)).toEqual( + groups.map((g) => + g.turn ? `turn:${g.turn.id}` : `turn:pre:${g.rows[0].id}` + ) + ); + }); +}); + +describe("toTurnEntry", () => { + it("anchors the entry on the turn row and moves updatedAt with the newest row", () => { + seq = 0; + const turnRow = row( + "turn", + { + state: "started", + prompt: { source: "system", text: "p" }, + }, + 1 + ); + const chunk = row("assistant", { text: "so far", streaming: true }, 2, 7); + const [group] = groupTurnRows([turnRow, chunk]); + const [turn] = assembleTurns([turnRow, chunk], new Map()); + const entry = toTurnEntry(turn, group, "agt_x"); + expect(entry).toMatchObject({ + type: "turn", + id: `turn:${turnRow.id}`, + agentId: "agt_x", + at: at(1).toISOString(), + updatedAt: at(7).toISOString(), + settled: false, + interrupted: false, + result: { text: "so far", streaming: true }, + }); + expect(entry.error).toBeUndefined(); + }); + + it("gives a pre-turn group the first row's id and reads it as settled", () => { + seq = 0; + const early = row("assistant", { text: "history", streaming: false }, 0, 1); + const [group] = groupTurnRows([early]); + const [turn] = assembleTurns([early], new Map()); + const entry = toTurnEntry(turn, group, "agt_x"); + expect(entry.id).toBe(`turn:pre:${early.id}`); + expect(entry.settled).toBe(true); + expect(entry.at).toBe(at(0).toISOString()); + }); + + it("reads a cancelled turn as interrupted", () => { + seq = 0; + const turnRow = row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "p" }, + stopReason: "cancelled", + endedAt: at(2).toISOString(), + }, + 0, + 2 + ); + const [group] = groupTurnRows([turnRow]); + const [turn] = assembleTurns([turnRow], new Map()); + const entry = toTurnEntry(turn, group, "agt_x"); + expect(entry.interrupted).toBe(true); + expect(entry.settled).toBe(true); + expect(entry.trace.finalResult).toBe("interrupted"); + }); + + it("reads a turn the service went down under as interrupted, not failed", () => { + seq = 0; + const turnRow = row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "p" }, + error: "interrupted by restart", + endedAt: at(4).toISOString(), + }, + 0, + 4 + ); + const [group] = groupTurnRows([turnRow]); + const [turn] = assembleTurns([turnRow], new Map()); + const entry = toTurnEntry(turn, group, "agt_x"); + expect(entry.interrupted).toBe(true); + expect(entry.trace.finalResult).toBe("interrupted"); + // The restart marker is not an engine failure, so it does not also + // render as an error line under the result. + expect(entry.error).toBeUndefined(); + }); + + it("carries an engine error through and turns questions into references", () => { + seq = 0; + const turnRow = row( + "turn", + { + state: "settled", + prompt: { source: "system", text: "p" }, + error: "no API key", + endedAt: at(5).toISOString(), + }, + 0, + 5 + ); + const question = { + id: "11111111-1111-4111-8111-111111111111", + agentId: "agt_x", + authorKind: "agent" as const, + kind: "question" as const, + text: "Which one?", + replyTo: null, + question: { options: [{ label: "A" }], allowFreeform: true }, + answer: null, + attachments: [], + delivered: null, + readAt: null, + createdAt: at(1).toISOString(), + updatedAt: at(1).toISOString(), + }; + const [group] = groupTurnRows([turnRow]); + const [turn] = assembleTurns([turnRow], new Map(), [question as never]); + const entry = toTurnEntry(turn, group, "agt_x"); + expect(entry.error).toBe("no API key"); + expect(entry.interrupted).toBe(false); + expect(entry.questions).toEqual([ + { messageId: "11111111-1111-4111-8111-111111111111", answered: false }, + ]); + }); +}); diff --git a/apps/server/test/configured-paths.test.ts b/apps/server/test/configured-paths.test.ts index 9e8c1be9a..0b853d77e 100644 --- a/apps/server/test/configured-paths.test.ts +++ b/apps/server/test/configured-paths.test.ts @@ -99,3 +99,62 @@ describe("configured paths expand a leading tilde", () => { ); }); }); + +/** + * The engine and CLI binary settings are the same story as the paths above, + * with one twist: a bare command name has to survive untouched so + * `resolveExecutable`'s PATH lookup still finds it. Only a value that names + * a path gets `~` expanded. + */ +describe("configured executables expand a leading tilde", () => { + const BIN_ENV = [ + "DISPATCH_CLAUDE_HARNESS_BIN", + "DISPATCH_CODEX_HARNESS_BIN", + "DISPATCH_GEMINI_BIN", + "DISPATCH_OPENCODE_BIN", + "DISPATCH_CLAUDE_BIN", + "DISPATCH_CODEX_BIN", + "DISPATCH_CURSOR_BIN", + "DATABASE_URL", + "DISPATCH_PORT", + "HOME", + ]; + + it("for every engine and CLI bin, and leaves a bare command name alone", async () => { + const saved = new Map(BIN_ENV.map((name) => [name, process.env[name]])); + const home = await mkdtemp(path.join(os.tmpdir(), "dispatch-cfg-bin-")); + cleanup.push(home); + try { + process.env.HOME = home; + // Neither the production database nor the production port: loadConfig + // refuses both from an agent context, and this suite runs in one. + process.env.DATABASE_URL = + "postgres://dispatch:dispatch@127.0.0.1:5433/dispatch_cfg_probe"; + process.env.DISPATCH_PORT = "6799"; + process.env.DISPATCH_CLAUDE_HARNESS_BIN = "~/.local/bin/claude-agent-acp"; + process.env.DISPATCH_CODEX_HARNESS_BIN = "~/.local/bin/codex-acp"; + process.env.DISPATCH_GEMINI_BIN = "~/.local/bin/gemini"; + process.env.DISPATCH_OPENCODE_BIN = "~/.local/bin/opencode"; + process.env.DISPATCH_CLAUDE_BIN = "~/.local/bin/claude"; + process.env.DISPATCH_CURSOR_BIN = "~/.local/bin/agent"; + // The one bare name in the set: PATH lookup, not a path. + process.env.DISPATCH_CODEX_BIN = "codex"; + vi.resetModules(); + const { loadConfig } = await import("../src/config.js"); + const config = loadConfig(); + const local = (name: string) => path.join(home, ".local", "bin", name); + expect(config.claudeHarnessBin).toBe(local("claude-agent-acp")); + expect(config.codexHarnessBin).toBe(local("codex-acp")); + expect(config.geminiBin).toBe(local("gemini")); + expect(config.opencodeBin).toBe(local("opencode")); + expect(config.claudeBin).toBe(local("claude")); + expect(config.cursorBin).toBe(local("agent")); + expect(config.codexBin).toBe("codex"); + } finally { + for (const [name, value] of saved) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } + }); +}); diff --git a/apps/server/test/db/agent-manager.test.ts b/apps/server/test/db/agent-manager.test.ts index 11af5260c..8bbe44e66 100644 --- a/apps/server/test/db/agent-manager.test.ts +++ b/apps/server/test/db/agent-manager.test.ts @@ -74,6 +74,9 @@ const testConfig = { claudeBin: "echo", opencodeBin: "echo", cursorBin: "echo", + claudeHarnessBin: "echo", + codexHarnessBin: "echo", + geminiBin: "echo", agentRuntime: "tmux", sessionPrefix: "dispatch", tls: null, @@ -1545,6 +1548,21 @@ describe("AgentManager", () => { expect(access.mode).toBe("inert"); expect(access.message).toContain("inert mode"); }); + + it("keeps an errored harness shell available for provider login", async () => { + const agent = await manager.createAgent({ + cwd: "/tmp", + useWorktree: false, + }); + await pool.query( + "UPDATE agents SET type = 'dispatch', status = 'error' WHERE id = $1", + [agent.id] + ); + + const access = await manager.getTerminalAccess(agent.id); + + expect(access).toEqual({ mode: "tmux", sessionName: agent.tmuxSession }); + }); }); describe("upsertLatestEvent", () => { diff --git a/apps/server/test/dispatch-harness-settings.test.ts b/apps/server/test/dispatch-harness-settings.test.ts new file mode 100644 index 000000000..0327d34e7 --- /dev/null +++ b/apps/server/test/dispatch-harness-settings.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import { + DEFAULT_ENABLED_AGENT_TYPES, + getOfferedAgentTypes, +} from "../src/agent-type-settings.js"; +import { + isDispatchHarnessEnabled, + setDispatchHarnessEnabled, +} from "../src/dispatch-harness-settings.js"; +import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; + +let pool: Pool; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +beforeEach(async () => { + await pool.query( + "DELETE FROM settings WHERE key IN ('dispatch_harness_enabled', 'enabled_agent_types')" + ); +}); + +describe("the Dispatch Harness flag", () => { + it("reads false on an install that has never set it", async () => { + expect(await isDispatchHarnessEnabled(pool)).toBe(false); + }); + + it("round trips true, then back to false", async () => { + await setDispatchHarnessEnabled(pool, true); + expect(await isDispatchHarnessEnabled(pool)).toBe(true); + + await setDispatchHarnessEnabled(pool, false); + expect(await isDispatchHarnessEnabled(pool)).toBe(false); + }); + + // The column is text, so anything could be in there. Only the exact + // string the setter writes counts as on. + it("reads false for a stored value that is not the string true", async () => { + await pool.query( + `INSERT INTO settings (key, value) VALUES ('dispatch_harness_enabled', 'yes') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value` + ); + expect(await isDispatchHarnessEnabled(pool)).toBe(false); + }); +}); + +/** Write the persisted enabled-types row directly, bypassing the sanitizer. */ +async function seedEnabledAgentTypes(types: string[]): Promise { + await pool.query( + `INSERT INTO settings (key, value) VALUES ('enabled_agent_types', $1) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [JSON.stringify(types)] + ); +} + +describe("getOfferedAgentTypes", () => { + it("is the enabled types with the flag off", async () => { + await seedEnabledAgentTypes(["claude", "codex"]); + expect(await getOfferedAgentTypes(pool)).toEqual(["claude", "codex"]); + }); + + it("adds the harness with the flag on", async () => { + await seedEnabledAgentTypes(["claude", "codex"]); + await setDispatchHarnessEnabled(pool, true); + expect(await getOfferedAgentTypes(pool)).toEqual([ + "claude", + "codex", + "dispatch", + ]); + }); + + it("drops the harness again when the flag goes off", async () => { + await seedEnabledAgentTypes(["claude"]); + await setDispatchHarnessEnabled(pool, true); + await setDispatchHarnessEnabled(pool, false); + expect(await getOfferedAgentTypes(pool)).toEqual(["claude"]); + }); + + it("offers the harness on an install that never saved a type choice", async () => { + await setDispatchHarnessEnabled(pool, true); + expect(await getOfferedAgentTypes(pool)).toEqual([ + ...DEFAULT_ENABLED_AGENT_TYPES, + "dispatch", + ]); + }); + + // A prerelease database carries `dispatch` inside the stored JSON (see + // db/migrate.ts's one-time rename). Nothing rewrites the row, so the read + // has to be what keeps the flag the only source, with no duplicate member + // when the flag is on. + it("never doubles the harness from a stale persisted row", async () => { + await seedEnabledAgentTypes(["claude", "dispatch", "terminal"]); + expect(await getOfferedAgentTypes(pool)).toEqual(["claude", "terminal"]); + + await setDispatchHarnessEnabled(pool, true); + expect(await getOfferedAgentTypes(pool)).toEqual([ + "claude", + "terminal", + "dispatch", + ]); + }); +}); diff --git a/apps/server/test/harness-agent-spec.test.ts b/apps/server/test/harness-agent-spec.test.ts new file mode 100644 index 000000000..62cac5a02 --- /dev/null +++ b/apps/server/test/harness-agent-spec.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { + engineSpecFor, + splitModelId, + type EngineBins, +} from "../src/agents/harness/agent-spec.js"; + +const bins: EngineBins = { + claudeHarnessBin: "/usr/local/bin/claude-agent-acp", + codexHarnessBin: "/usr/local/bin/codex-acp", + geminiBin: "/usr/local/bin/gemini", + opencodeBin: "/usr/local/bin/opencode", + claudeBin: "/home/u/.local/bin/claude", + codexBin: null, +}; + +describe("splitModelId", () => { + it("splits at the first slash so OpenCode's provider/model survives", () => { + expect(splitModelId("claude/default")).toEqual({ + engine: "claude", + model: "default", + }); + expect(splitModelId("opencode/anthropic/claude-sonnet-5")).toEqual({ + engine: "opencode", + model: "anthropic/claude-sonnet-5", + }); + }); + + it("rejects ids without an engine or with an unknown one", () => { + expect(() => splitModelId("gpt-5.6-sol")).toThrow(/engine\/model/); + expect(() => splitModelId("nope/v4")).toThrow(/unknown engine/); + expect(() => splitModelId("claude/")).toThrow(/engine\/model/); + }); +}); + +describe("engineSpecFor", () => { + it("claude: the adapter, skip-permissions, the host claude, system-prompt persona, nested subagents", () => { + const spec = engineSpecFor("claude", "default", bins); + expect(spec).toMatchObject({ + id: "claude", + bin: "/usr/local/bin/claude-agent-acp", + args: ["--dangerously-skip-permissions"], + env: { CLAUDE_CODE_EXECUTABLE: "/home/u/.local/bin/claude" }, + personaDelivery: "system_prompt", + fullAccess: { kind: "args" }, + subagentTranscripts: true, + modelFixedAtLaunch: false, + }); + }); + + it("codex: full access by env, bundled codex unless a host codex is configured", () => { + expect(engineSpecFor("codex", "gpt-5.6-sol", bins)).toMatchObject({ + bin: "/usr/local/bin/codex-acp", + args: [], + env: { INITIAL_AGENT_MODE: "agent-full-access", NO_BROWSER: "1" }, + personaDelivery: "first_prompt", + fullAccess: { kind: "env" }, + subagentTranscripts: false, + }); + expect( + engineSpecFor("codex", "default", { ...bins, codexBin: "/bin/codex" }).env + ).toMatchObject({ CODEX_PATH: "/bin/codex" }); + expect(engineSpecFor("codex", "default", bins).env).not.toHaveProperty( + "CODEX_PATH" + ); + }); + + it("gemini: the acp flag, the model as a launch flag, yolo by set_mode", () => { + expect(engineSpecFor("gemini", "gemini-3-pro-preview", bins)).toMatchObject( + { + bin: "/usr/local/bin/gemini", + args: ["--experimental-acp", "--model", "gemini-3-pro-preview"], + env: {}, + personaDelivery: "first_prompt", + fullAccess: { kind: "set_mode", modeId: "yolo" }, + modelFixedAtLaunch: true, + } + ); + expect(engineSpecFor("gemini", "default", bins).args).toEqual([ + "--experimental-acp", + ]); + }); + + it("opencode: the acp subcommand, permissions answered by the driver", () => { + expect(engineSpecFor("opencode", "default", bins)).toMatchObject({ + bin: "/usr/local/bin/opencode", + args: ["acp"], + env: {}, + personaDelivery: "first_prompt", + fullAccess: { kind: "permission_request" }, + subagentTranscripts: false, + modelFixedAtLaunch: false, + }); + }); +}); diff --git a/apps/server/test/harness-auth-status.test.ts b/apps/server/test/harness-auth-status.test.ts new file mode 100644 index 000000000..92aee1fec --- /dev/null +++ b/apps/server/test/harness-auth-status.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + loadHarnessAuthReport, + parseClaudeAuth, + parseCodexAuth, + parseGeminiAuth, +} from "../src/agents/harness/auth-status.js"; + +describe("harness auth status", () => { + it("distinguishes subscription seats from API keys", () => { + expect(parseCodexAuth("Logged in using ChatGPT")).toMatchObject({ + kind: "subscription", + label: "ChatGPT subscription", + }); + expect(parseCodexAuth("Logged in using an API key")).toMatchObject({ + kind: "api_key", + label: "OpenAI API key", + }); + expect( + parseClaudeAuth( + JSON.stringify({ + loggedIn: true, + authMethod: "claude.ai", + subscriptionType: "team", + }) + ) + ).toMatchObject({ + kind: "subscription", + label: "Claude team subscription", + }); + expect(parseGeminiAuth("gemini-api-key")).toMatchObject({ + kind: "api_key", + label: "Google API key", + }); + expect(parseGeminiAuth("oauth-personal")).toMatchObject({ + kind: "oauth", + label: "Google account", + }); + }); + + it("returns only sanitized labels from host probes", async () => { + const runner = vi.fn(async (command: string) => ({ + exitCode: 0, + stdout: + command === "codex" + ? "Logged in using ChatGPT" + : command === "claude" + ? JSON.stringify({ loggedIn: true, authMethod: "apiKey" }) + : "1 credential", + stderr: "", + })); + const report = await loadHarnessAuthReport( + { + claude: "claude", + codex: "codex", + gemini: "gemini", + opencode: "opencode", + }, + { + runner, + read: async () => + JSON.stringify({ + security: { auth: { selectedType: "oauth-personal" } }, + }), + homeDir: "/home/service", + now: new Date("2026-09-11T00:00:00Z"), + } + ); + expect(report.checkedAt).toBe("2026-09-11T00:00:00.000Z"); + expect( + report.engines.map(({ engineId, kind }) => ({ engineId, kind })) + ).toEqual([ + { engineId: "claude", kind: "api_key" }, + { engineId: "codex", kind: "subscription" }, + { engineId: "gemini", kind: "oauth" }, + { engineId: "opencode", kind: "configured" }, + ]); + expect(JSON.stringify(report)).not.toContain("credential"); + }); +}); diff --git a/apps/server/test/harness-driver.test.ts b/apps/server/test/harness-driver.test.ts new file mode 100644 index 000000000..3d1fd74eb --- /dev/null +++ b/apps/server/test/harness-driver.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + engineSpecFor, + type EngineBins, +} from "../src/agents/harness/agent-spec.js"; +import { + HarnessDriver, + type DriverEvent, + type DriverLaunch, +} from "../src/agents/harness/driver.js"; +import { createFakeAcpAgent } from "./helpers/fake-acp-agent.js"; + +const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + +/** The fake is spawned in-process, so skip the PATH lookup. */ +const resolveBinary = async (bin: string) => bin; + +const bins: EngineBins = { + claudeHarnessBin: "/bin/claude-agent-acp", + codexHarnessBin: "/bin/codex-acp", + geminiBin: "/bin/gemini", + opencodeBin: "/bin/opencode", + claudeBin: "/home/u/.local/bin/claude", + codexBin: null, +}; + +function launch( + overrides: Partial = {}, + engine: Parameters[0] = "claude", + model = "default" +): DriverLaunch { + return { + agentId: "agt_1", + cwd: "/tmp/w", + engine: engineSpecFor(engine, model, bins), + systemPromptAppend: engine === "claude" ? "Be brief." : null, + mcp: { url: "http://127.0.0.1:1/api/mcp/agt_1", token: "tok" }, + sessionId: null, + env: { PATH: "/usr/bin", HOME: "/home/u" }, + ...overrides, + }; +} + +function driverWith(fake: ReturnType) { + const spawn = vi.fn(() => fake.child); + return { spawn, driver: new HarnessDriver({ spawn, resolveBinary, logger }) }; +} + +describe("HarnessDriver", () => { + it("claude: spawns the adapter with its args and env, declares subagent transcripts, sends the persona in _meta", async () => { + const fake = createFakeAcpAgent(); + const { spawn, driver } = driverWith(fake); + const { sessionId } = await driver.start(launch()); + expect(sessionId).toBe("sess_1"); + expect(spawn).toHaveBeenCalledWith( + "/bin/claude-agent-acp", + ["--dangerously-skip-permissions"], + expect.objectContaining({ + cwd: "/tmp/w", + env: expect.objectContaining({ + CLAUDE_CODE_EXECUTABLE: "/home/u/.local/bin/claude", + HOME: "/home/u", + PATH: "/usr/bin", + }), + }) + ); + expect(fake.seen.initialize[0].clientCapabilities?._meta).toEqual({ + "subagent-transcript": true, + }); + const req = fake.seen.newSession[0]; + expect(req.cwd).toBe("/tmp/w"); + expect(req._meta).toEqual({ systemPrompt: { append: "Be brief." } }); + expect(req.mcpServers).toEqual([ + { + type: "http", + name: "dispatch", + url: "http://127.0.0.1:1/api/mcp/agt_1", + headers: [{ name: "Authorization", value: "Bearer tok" }], + }, + ]); + expect(fake.seen.setMode).toEqual([]); + await driver.stop("agt_1"); + }); + + it("codex: no _meta persona, no subagent capability, full access by env", async () => { + const fake = createFakeAcpAgent(); + const { spawn, driver } = driverWith(fake); + await driver.start(launch({}, "codex", "gpt-5.6-sol")); + expect(spawn).toHaveBeenCalledWith( + "/bin/codex-acp", + [], + expect.objectContaining({ + env: expect.objectContaining({ + INITIAL_AGENT_MODE: "agent-full-access", + NO_BROWSER: "1", + }), + }) + ); + expect(fake.seen.initialize[0].clientCapabilities?._meta).toBeUndefined(); + expect(fake.seen.newSession[0]._meta).toBeUndefined(); + await driver.stop("agt_1"); + }); + + it("gemini: sets the yolo mode right after the session opens, on new and on resume", async () => { + const fake = createFakeAcpAgent(); + const { spawn, driver } = driverWith(fake); + await driver.start(launch({}, "gemini", "gemini-2.5-pro")); + expect(spawn.mock.calls[0][1]).toEqual([ + "--experimental-acp", + "--model", + "gemini-2.5-pro", + ]); + expect(fake.seen.setMode).toEqual([ + { sessionId: "sess_1", modeId: "yolo" }, + ]); + await driver.stop("agt_1"); + const again = createFakeAcpAgent(); + const second = driverWith(again).driver; + await second.start(launch({ sessionId: "sess_1" }, "gemini")); + expect(again.seen.resumeSession).toHaveLength(1); + expect(again.seen.setMode).toEqual([ + { sessionId: "sess_1", modeId: "yolo" }, + ]); + await second.stop("agt_1"); + }); + + it("keeps the commands the engine advertises", async () => { + const fake = createFakeAcpAgent({ + commands: [ + { name: "review", description: "Review the branch", input: null }, + { name: "compact", description: "Compact", input: { hint: "focus" } }, + ], + }); + const { driver } = driverWith(fake); + await driver.start(launch({}, "opencode")); + await new Promise((r) => setTimeout(r, 10)); + expect(driver.getCommands("agt_1")?.map((c) => c.name)).toEqual([ + "review", + "compact", + ]); + expect(driver.getCommands("agt_nope")).toBeNull(); + await driver.stop("agt_1"); + }); + + it("resumes over session/resume and sends the persona again for claude", async () => { + const fake = createFakeAcpAgent(); + const { driver } = driverWith(fake); + const { sessionId, resumed } = await driver.start( + launch({ sessionId: "sess_prev" }) + ); + expect({ sessionId, resumed }).toEqual({ + sessionId: "sess_prev", + resumed: true, + }); + expect(fake.seen.newSession).toHaveLength(0); + expect(fake.seen.resumeSession[0]).toMatchObject({ + sessionId: "sess_prev", + cwd: "/tmp/w", + _meta: { systemPrompt: { append: "Be brief." } }, + }); + await driver.stop("agt_1"); + }); + + it("forwards updates and turn boundaries while a prompt runs", async () => { + const fake = createFakeAcpAgent({ + turn: async (_p, emit) => { + await emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hi" }, + }); + return "end_turn"; + }, + }); + const driver = new HarnessDriver({ + spawn: () => fake.child, + resolveBinary, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + await driver.prompt("agt_1", "hello"); + expect(fake.seen.prompts).toEqual(["hello"]); + expect(events.map((e) => e.type)).toEqual(["turn", "update", "turn"]); + expect(events[2]).toMatchObject({ + type: "turn", + state: "settled", + stopReason: "end_turn", + }); + await driver.stop("agt_1"); + }); + + it("stop closes the session and reaps the child", async () => { + const fake = createFakeAcpAgent(); + const driver = new HarnessDriver({ + spawn: () => fake.child, + resolveBinary, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + await driver.stop("agt_1"); + expect(fake.seen.closes).toBe(1); + expect(driver.isRunning("agt_1")).toBe(false); + expect(events.at(-1)).toMatchObject({ + type: "exit", + agentId: "agt_1", + expected: true, + }); + }); + + it("refuses to start twice for one agent", async () => { + const fake = createFakeAcpAgent(); + const driver = new HarnessDriver({ + spawn: () => fake.child, + resolveBinary, + logger, + }); + await driver.start(launch()); + await expect(driver.start(launch())).rejects.toThrow(/already running/); + await driver.stop("agt_1"); + }); + + it("a prompt rejected by the agent settles the turn with an error", async () => { + const fake = createFakeAcpAgent({ + turn: async () => { + throw new Error("no API key"); + }, + }); + const driver = new HarnessDriver({ + spawn: () => fake.child, + resolveBinary, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + await expect(driver.prompt("agt_1", "x")).rejects.toThrow(/no API key/); + expect(events.at(-1)).toMatchObject({ + type: "turn", + state: "settled", + error: expect.stringContaining("no API key"), + }); + await driver.stop("agt_1"); + }); + + it("prompting an agent that is not running throws", async () => { + const driver = new HarnessDriver({ + spawn: () => createFakeAcpAgent().child, + resolveBinary, + logger, + }); + await expect(driver.prompt("agt_nope", "x")).rejects.toThrow(/not running/); + }); + + it("fails the start, not the process, when the binary cannot be spawned", async () => { + const driver = new HarnessDriver({ resolveBinary, logger }); + await expect( + driver.start( + launch({ + engine: { + ...engineSpecFor("claude", "default", bins), + bin: "definitely-not-a-real-binary-xyz", + }, + }) + ) + ).rejects.toThrow(/harness start failed: the harness could not be spawned/); + expect(driver.isRunning("agt_1")).toBe(false); + }); + + it("names the missing binary before spawning", async () => { + const driver = new HarnessDriver({ logger }); + await expect( + driver.start( + launch({ + engine: { + ...engineSpecFor("claude", "default", bins), + bin: "definitely-not-a-real-binary-xyz", + }, + }) + ) + ).rejects.toThrow(/was not found on the server's PATH/); + }); + + it("falls back to a new session when the stored one cannot be resumed", async () => { + const fake = createFakeAcpAgent({ resumeFails: true }); + const driver = new HarnessDriver({ + spawn: () => fake.child, + resolveBinary, + logger, + }); + const result = await driver.start({ ...launch(), sessionId: "sess_gone" }); + expect(result).toEqual({ sessionId: "sess_1", resumed: false }); + expect(fake.seen.resumeSession).toHaveLength(1); + expect(fake.seen.newSession).toHaveLength(1); + await driver.stop("agt_1"); + }); + + it("reports an unexpected child death as a crash", async () => { + const fake = createFakeAcpAgent(); + const driver = new HarnessDriver({ + spawn: () => fake.child, + resolveBinary, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + fake.child.kill("SIGKILL"); + await new Promise((r) => setTimeout(r, 0)); + expect(events.at(-1)).toMatchObject({ type: "exit", expected: false }); + expect(driver.isRunning("agt_1")).toBe(false); + }); + + it("cancels a permission request that offers no allow option", async () => { + const fake = createFakeAcpAgent({ + turn: async (_p, _emit, ask) => { + const answer = await ask({ + options: [{ optionId: "no", name: "Reject", kind: "reject_once" }], + }); + return answer.outcome.outcome === "cancelled" + ? "cancelled" + : "end_turn"; + }, + }); + const driver = new HarnessDriver({ + spawn: () => fake.child, + resolveBinary, + logger, + }); + const events: DriverEvent[] = []; + driver.onEvent((e) => events.push(e)); + await driver.start(launch()); + await driver.prompt("agt_1", "x"); + expect(events.at(-1)).toMatchObject({ + state: "settled", + stopReason: "cancelled", + }); + await driver.stop("agt_1"); + }); +}); diff --git a/apps/server/test/harness-engines-shared.test.ts b/apps/server/test/harness-engines-shared.test.ts new file mode 100644 index 000000000..941b37013 --- /dev/null +++ b/apps/server/test/harness-engines-shared.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_HARNESS_MODEL, + HARNESS_ENGINES, + harnessEngineOf, +} from "@dispatch/shared"; + +describe("HARNESS_ENGINES", () => { + it("lists the four engines in create-dialog order", () => { + expect(HARNESS_ENGINES.map((e) => e.id)).toEqual([ + "claude", + "codex", + "gemini", + "opencode", + ]); + }); + + it("knows which engines publish a plan, a model option, usage, and cost", () => { + const by = Object.fromEntries(HARNESS_ENGINES.map((e) => [e.id, e])); + expect(by.claude).toMatchObject({ + publishesPlan: true, + publishesModelOption: true, + reportsUsage: true, + reportsCost: true, + }); + expect(by.codex).toMatchObject({ + publishesPlan: true, + publishesModelOption: true, + reportsUsage: true, + reportsCost: false, + }); + expect(by.gemini).toMatchObject({ + publishesPlan: false, + publishesModelOption: false, + reportsUsage: false, + reportsCost: false, + }); + expect(by.opencode).toMatchObject({ + publishesPlan: false, + publishesModelOption: true, + reportsUsage: true, + reportsCost: true, + }); + }); + + it("resolves an engine from a model id prefix", () => { + expect(harnessEngineOf("codex/gpt-5.6-sol")?.id).toBe("codex"); + expect(harnessEngineOf("opencode/anthropic/claude-sonnet-5")?.id).toBe( + "opencode" + ); + expect(harnessEngineOf(DEFAULT_HARNESS_MODEL)?.id).toBe("claude"); + expect(harnessEngineOf("gpt-5.6-sol")).toBeNull(); + expect(harnessEngineOf("nope/x")).toBeNull(); + // No model stored means the default engine, not "no engine": the usage + // report and the pane's login hint both key off this. + expect(harnessEngineOf(null)?.id).toBe("claude"); + expect(harnessEngineOf(undefined)?.id).toBe("claude"); + expect(harnessEngineOf("")?.id).toBe("claude"); + expect(harnessEngineOf("/claude")).toBeNull(); + }); +}); diff --git a/apps/server/test/harness-paths.test.ts b/apps/server/test/harness-paths.test.ts new file mode 100644 index 000000000..72f99071a --- /dev/null +++ b/apps/server/test/harness-paths.test.ts @@ -0,0 +1,151 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { + listHarnessPaths, + resolvePathQuery, +} from "../src/agents/harness/paths.js"; + +let root: string; +let cwd: string; +let home: string; + +beforeAll(async () => { + root = await mkdtemp(path.join(os.tmpdir(), "harness-paths-")); + cwd = path.join(root, "repo"); + home = path.join(root, "home"); + await mkdir(path.join(cwd, "apps", "web"), { recursive: true }); + await mkdir(path.join(cwd, "apps", "server"), { recursive: true }); + await mkdir(path.join(cwd, "docs"), { recursive: true }); + await mkdir(path.join(cwd, ".dispatch"), { recursive: true }); + await writeFile(path.join(cwd, "README.md"), "# hi\n"); + await writeFile(path.join(cwd, "apps", "notes.txt"), "n\n"); + await writeFile(path.join(cwd, ".env"), "x=1\n"); + await symlink(path.join(cwd, "docs"), path.join(cwd, "docs-link")); + await mkdir(path.join(home, "src"), { recursive: true }); + await writeFile(path.join(home, "notes.md"), "n\n"); + await writeFile(path.join(root, "outside.txt"), "o\n"); +}); + +afterAll(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("resolvePathQuery", () => { + it("resolves relative, home, and absolute prefixes", () => { + expect(resolvePathQuery("ap", { cwd, home })).toEqual({ + dir: cwd, + typedDir: "", + segment: "ap", + }); + expect(resolvePathQuery("apps/we", { cwd, home })).toEqual({ + dir: path.join(cwd, "apps/"), + typedDir: "apps/", + segment: "we", + }); + expect(resolvePathQuery("~/sr", { cwd, home })).toEqual({ + dir: path.join(home, ""), + typedDir: "~/", + segment: "sr", + }); + expect(resolvePathQuery("/tmp/x", { cwd, home })).toEqual({ + dir: "/tmp/", + typedDir: "/tmp/", + segment: "x", + }); + }); + + it("refuses NUL bytes and over-long queries", () => { + expect(resolvePathQuery("a\0b", { cwd, home })).toBeNull(); + expect(resolvePathQuery("x".repeat(2000), { cwd, home })).toBeNull(); + }); +}); + +describe("listHarnessPaths", () => { + it("lists the working tree, directories first, hidden entries only when asked", async () => { + expect(await listHarnessPaths("", { cwd, home })).toEqual([ + { path: "apps", kind: "dir" }, + { path: "docs", kind: "dir" }, + { path: "docs-link", kind: "dir" }, + { path: "README.md", kind: "file" }, + ]); + expect(await listHarnessPaths(".", { cwd, home })).toEqual([ + { path: ".dispatch", kind: "dir" }, + { path: ".env", kind: "file" }, + ]); + }); + + it("matches the last segment case-insensitively and keeps the typed prefix", async () => { + expect(await listHarnessPaths("apps/S", { cwd, home })).toEqual([ + { path: "apps/server", kind: "dir" }, + ]); + expect(await listHarnessPaths("apps/", { cwd, home })).toEqual([ + { path: "apps/server", kind: "dir" }, + { path: "apps/web", kind: "dir" }, + { path: "apps/notes.txt", kind: "file" }, + ]); + expect(await listHarnessPaths("~/s", { cwd, home })).toEqual([ + { path: "~/src", kind: "dir" }, + ]); + expect(await listHarnessPaths("~", { cwd, home })).toEqual([ + { path: "~", kind: "dir" }, + ]); + }); + + it("keeps directories when many files sort ahead of them", async () => { + const big = path.join(root, "big"); + await mkdir(big); + for (let i = 0; i < 120; i += 1) { + await writeFile(path.join(big, `a${String(i).padStart(3, "0")}.txt`), ""); + } + for (let i = 0; i < 5; i += 1) await mkdir(path.join(big, `zdir${i}`)); + const out = await listHarnessPaths("big/", { cwd: root }); + expect(out).toHaveLength(50); + expect(out.slice(0, 5).map((p) => p.path)).toEqual([ + "big/zdir0", + "big/zdir1", + "big/zdir2", + "big/zdir3", + "big/zdir4", + ]); + expect(out.slice(5).every((p) => p.kind === "file")).toBe(true); + }); + + it("lists only directories outside the agent's working tree", async () => { + // Parity with /api/v1/system/path-completions, which lists directories + // only. Inside the tree the picker is meant to name files; outside it, + // enumerating file names is not what the picker is for. + const outside = await listHarnessPaths(`${root}/`, { cwd, home }); + expect(outside.every((entry) => entry.kind === "dir")).toBe(true); + expect(outside.map((entry) => entry.path)).toContain(`${root}/repo`); + expect(await listHarnessPaths("~/", { cwd, home })).toEqual([ + { path: "~/src", kind: "dir" }, + ]); + // Inside the tree files still list, which is the case above this one. + expect(await listHarnessPaths("", { cwd, home })).toContainEqual({ + path: "README.md", + kind: "file", + }); + }); + + it("does not read a macOS privacy-protected directory", async () => { + // A service that touches ~/Desktop and friends on macOS gets a TCC + // prompt no daemon can answer, or a silent denial; the existing + // completion route refuses these before readdir for the same reason. + await mkdir(path.join(home, "Desktop", "sub"), { recursive: true }); + expect( + await listHarnessPaths("~/Desktop/", { cwd, home, platform: "darwin" }) + ).toEqual([]); + // The same path on this platform is an ordinary directory. + expect( + await listHarnessPaths("~/Desktop/", { cwd, home, platform: "linux" }) + ).toEqual([{ path: "~/Desktop/sub", kind: "dir" }]); + }); + + it("answers nothing for a directory that does not exist", async () => { + expect(await listHarnessPaths("nope/x", { cwd, home })).toEqual([]); + expect(await listHarnessPaths("a\0b", { cwd, home })).toEqual([]); + }); +}); diff --git a/apps/server/test/harness-persona.test.ts b/apps/server/test/harness-persona.test.ts new file mode 100644 index 000000000..dc542d6ff --- /dev/null +++ b/apps/server/test/harness-persona.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { + buildHarnessPersona, + HARNESS_CHAT_RULE, +} from "../src/agents/harness/persona.js"; + +const base = { + id: "agt_p", + type: "dispatch" as const, + agentArgs: [] as string[], + persona: null, + autoReview: false, +}; + +describe("buildHarnessPersona", () => { + it("starts with the Dispatch launch guidance", () => { + const text = buildHarnessPersona({ + agent: base, + personalityPrompt: null, + trimmedGuidance: false, + chatSurface: false, + suggestSessionRename: false, + }); + expect(text).toContain("dispatch_event"); + expect(text).toContain(HARNESS_CHAT_RULE); + expect(text).not.toContain("Send every user-facing reply"); + }); + + it("appends the active personality for a standard agent", () => { + const text = buildHarnessPersona({ + agent: base, + personalityPrompt: "Be terse.", + trimmedGuidance: false, + chatSurface: true, + suggestSessionRename: false, + }); + expect(text.endsWith("Be terse.")).toBe(true); + }); + + it("prefers the persona brief stored in agentArgs over a personality", () => { + const text = buildHarnessPersona({ + agent: { + ...base, + persona: "security-review", + agentArgs: ["--append-system-prompt", "You review for security."], + }, + personalityPrompt: "Be terse.", + trimmedGuidance: false, + chatSurface: false, + suggestSessionRename: false, + }); + expect(text).toContain("You review for security."); + expect(text).not.toContain("Be terse."); + }); +}); + +describe("buildHarnessPersona for a job run", () => { + it("names the job tools in the guidance", () => { + const text = buildHarnessPersona({ + agent: base, + personalityPrompt: null, + trimmedGuidance: false, + suggestSessionRename: false, + jobRunId: "run_42", + }); + expect(text).toContain("Dispatch job startup"); + }); +}); diff --git a/apps/server/test/harness-prompt-source.test.ts b/apps/server/test/harness-prompt-source.test.ts new file mode 100644 index 000000000..3ddce313c --- /dev/null +++ b/apps/server/test/harness-prompt-source.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { parsePromptSource } from "../src/agents/harness/prompt-source.js"; + +describe("parsePromptSource", () => { + it("reads the chat message id out of a chat envelope", () => { + const text = [ + "--- DISPATCH CHAT (id: fae1f052-5d66-4039-9bde-35ac8166695d) ---", + "hello", + "--- END DISPATCH CHAT ---", + "The user is reading Chat…", + ].join("\n"); + expect(parsePromptSource(text)).toEqual({ + source: "chat", + chatMessageId: "fae1f052-5d66-4039-9bde-35ac8166695d", + }); + }); + + it("does not read a chat id out of a header that is not a real UUID", () => { + // The header is matched on every prompt that reaches the queue, review + // injection prompts included, and those embed feedback bodies verbatim. + // A captured value that is not a UUID reaches a `::uuid[]` cast and + // turns every later read of that agent's turns into a 500. + for (const id of [ + "0".repeat(36), + "-".repeat(36), + "fae1f052-5d664039-9bde-35ac8166695dd", + ]) { + const text = [ + `--- DISPATCH CHAT (id: ${id}) ---`, + "hello", + "--- END DISPATCH CHAT ---", + ].join("\n"); + expect(parsePromptSource(text).source).toBe("system"); + } + }); + + it("reads sender and text out of a cross-agent message envelope", () => { + const body = JSON.stringify({ + from: "Dispatch Harness Research", + senderId: "agt_683b115bc1e9", + senderRelation: "unrelated", + message: "Quick check: which branch?", + replyTarget: "agt_683b115bc1e9", + }); + const text = `--- DISPATCH MESSAGE ---\n${body}\n--- END MESSAGE ---\nOptional reply channel…`; + expect(parsePromptSource(text)).toEqual({ + source: "agent", + senderId: "agt_683b115bc1e9", + senderName: "Dispatch Harness Research", + text: "Quick check: which branch?", + }); + }); + + it("keeps the first 500 characters of anything else as a system prompt", () => { + const text = "x".repeat(600); + expect(parsePromptSource(text)).toEqual({ + source: "system", + text: "x".repeat(500), + }); + }); +}); diff --git a/apps/server/test/harness-provider-usage.test.ts b/apps/server/test/harness-provider-usage.test.ts new file mode 100644 index 000000000..035e42276 --- /dev/null +++ b/apps/server/test/harness-provider-usage.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + loadHarnessProviderUsage, + parseClaudeProviderUsage, + parseCodexProviderUsage, +} from "../src/agents/harness/provider-usage.js"; + +describe("harness provider usage", () => { + it("reads Claude plan windows and extra usage without account metadata", () => { + const result = parseClaudeProviderUsage( + JSON.stringify({ + cachedUsageUtilization: { + fetchedAtMs: Date.parse("2026-09-11T01:00:00Z"), + accountUuid: "must-not-leak", + utilization: { + limits: [ + { + kind: "session", + percent: 30, + resets_at: "2026-09-11T03:00:00Z", + }, + { + kind: "weekly_scoped", + percent: 98, + resets_at: "2026-09-12T02:00:00Z", + scope: { model: { display_name: "Fable" } }, + }, + ], + spend: { + used: { amount_minor: 2010, exponent: 2, currency: "USD" }, + limit: { amount_minor: 5000, exponent: 2, currency: "USD" }, + }, + }, + }, + }) + ); + + expect(result).toMatchObject({ + engineId: "claude", + observedAt: "2026-09-11T01:00:00.000Z", + windows: [ + { id: "session", label: "5-hour", usedPercent: 30 }, + { + id: "weekly_scoped:Fable", + label: "Fable weekly", + usedPercent: 98, + }, + ], + spend: { used: 20.1, limit: 50, currency: "USD" }, + }); + expect(JSON.stringify(result)).not.toContain("must-not-leak"); + }); + + it("reads Codex plan windows from the newest token count", () => { + const result = parseCodexProviderUsage( + [ + JSON.stringify({ + timestamp: "2026-09-11T01:00:00Z", + type: "event_msg", + payload: { + type: "token_count", + rate_limits: { + plan_type: "plus", + primary: { + used_percent: 12, + window_minutes: 300, + resets_at: 1789017210, + }, + }, + }, + }), + JSON.stringify({ + timestamp: "2026-09-11T02:00:00Z", + type: "event_msg", + payload: { + type: "token_count", + rate_limits: { + plan_type: "team", + primary: { + used_percent: 25, + window_minutes: 300, + resets_at: 1789017210, + }, + secondary: { + used_percent: 40, + window_minutes: 10080, + resets_at: 1789447706, + }, + }, + }, + }), + ].join("\n") + ); + + expect(result.plan).toBe("Team"); + expect(result.observedAt).toBe("2026-09-11T02:00:00Z"); + expect(result.windows).toEqual([ + expect.objectContaining({ label: "5-hour", usedPercent: 25 }), + expect.objectContaining({ label: "Weekly", usedPercent: 40 }), + ]); + }); + + it("uses the newest rollout carrying provider limits", async () => { + const files = ["/logs/old.jsonl", "/logs/new.jsonl"]; + const report = await loadHarnessProviderUsage({ + now: new Date("2026-09-11T03:00:00Z"), + homeDir: "/home/service", + codexFiles: async () => files, + modifiedAt: async (file) => (file.includes("new") ? 2 : 1), + read: async (file) => { + if (file.endsWith(".claude.json")) throw new Error("missing"); + const used = file.includes("new") ? 70 : 20; + return JSON.stringify({ + timestamp: "2026-09-11T02:30:00Z", + type: "event_msg", + payload: { + type: "token_count", + rate_limits: { + primary: { used_percent: used, window_minutes: 300 }, + }, + }, + }); + }, + }); + + expect(report.checkedAt).toBe("2026-09-11T03:00:00.000Z"); + expect(report.providers).toHaveLength(4); + expect( + report.providers.find((item) => item.engineId === "codex") + ).toMatchObject({ + windows: [expect.objectContaining({ usedPercent: 70 })], + }); + }); +}); diff --git a/apps/server/test/harness-routes.test.ts b/apps/server/test/harness-routes.test.ts new file mode 100644 index 000000000..bfa0f669e --- /dev/null +++ b/apps/server/test/harness-routes.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import Fastify from "fastify"; + +import { registerAgentHarnessRoutes } from "../src/routes/agents/harness-routes.js"; +import { useInjectApp } from "./helpers/inject-app.js"; + +const ctx = useInjectApp(); + +async function authedGet(url: string) { + const cookie = await ctx.sessionCookie(); + return ctx.app.inject({ method: "GET", url, headers: { cookie } }); +} + +async function createAgent(name: string): Promise { + const cookie = await ctx.sessionCookie(); + const res = await ctx.app.inject({ + method: "POST", + url: "/api/v1/agents", + headers: { cookie, "content-type": "application/json" }, + payload: { cwd: "/tmp", useWorktree: false, name }, + }); + expect(res.statusCode).toBe(201); + return res.json().agent.id as string; +} + +let agentId: string; + +beforeEach(async () => { + await ctx.pool.query("DELETE FROM agent_stream_events"); + await ctx.pool.query("DELETE FROM agent_chat_messages"); + await ctx.pool.query("DELETE FROM agents"); + agentId = await createAgent("Harnessed"); +}); + +describe("the retired turns route", () => { + // The turns endpoint was the second reader of agent_stream_events; the + // chat feed's `turn` entries replaced it. The queue route is the control: + // it proves the 404 is this route's absence and not a broken prefix. + it("404s where the queue route on the same prefix still answers", async () => { + const turns = await authedGet(`/api/v1/agents/${agentId}/harness/turns`); + expect(turns.statusCode).toBe(404); + const queue = await authedGet(`/api/v1/agents/${agentId}/harness/queue`); + expect(queue.statusCode).toBe(200); + }); +}); + +describe("POST /api/v1/agents/:id/harness/interrupt", () => { + it("409s when nothing is running and 404s for an unknown agent", async () => { + const cookie = await ctx.sessionCookie(); + const idle = await ctx.app.inject({ + method: "POST", + url: `/api/v1/agents/${agentId}/harness/interrupt`, + headers: { cookie }, + }); + expect(idle.statusCode).toBe(409); + const missing = await ctx.app.inject({ + method: "POST", + url: `/api/v1/agents/agt_nope/harness/interrupt`, + headers: { cookie }, + }); + expect(missing.statusCode).toBe(404); + }); +}); + +describe("harness queue routes", () => { + it("404 when the message is not queued, and for an unknown agent", async () => { + const cookie = await ctx.sessionCookie(); + const sendNow = await ctx.app.inject({ + method: "POST", + url: `/api/v1/agents/${agentId}/harness/queue/not-queued/send-now`, + headers: { cookie }, + }); + expect(sendNow.statusCode).toBe(404); + expect(sendNow.json().error).toMatch(/no longer queued/); + const remove = await ctx.app.inject({ + method: "DELETE", + url: `/api/v1/agents/${agentId}/harness/queue/not-queued`, + headers: { cookie }, + }); + expect(remove.statusCode).toBe(404); + const missing = await ctx.app.inject({ + method: "DELETE", + url: `/api/v1/agents/agt_nope/harness/queue/x`, + headers: { cookie }, + }); + expect(missing.statusCode).toBe(404); + expect(missing.json().error).toBe("Agent not found."); + }); +}); + +describe("GET /api/v1/agents/:id/harness/commands", () => { + it("404s for an unknown agent and returns an empty list for one with no live session", async () => { + expect( + (await authedGet("/api/v1/agents/agt_nope/harness/commands")).statusCode + ).toBe(404); + const res = await authedGet(`/api/v1/agents/${agentId}/harness/commands`); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ commands: [] }); + }); +}); + +describe("GET /api/v1/agents/:id/harness/usage", () => { + it("reports the agent's month, null cost when the engine sent none", async () => { + await ctx.pool.query( + `UPDATE agents SET type = 'dispatch', model = 'codex/default' WHERE id = $1`, + [agentId] + ); + const res = await authedGet(`/api/v1/agents/${agentId}/harness/usage`); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ + agent: { agentId, tokens: 0, costUsd: null }, + }); + expect(typeof res.json().monthStart).toBe("string"); + }); +}); + +describe("GET /api/v1/harness/usage", () => { + it("lists the four engines", async () => { + const res = await authedGet("/api/v1/harness/usage"); + expect(res.statusCode).toBe(200); + expect(res.json().engines.map((e: { id: string }) => e.id)).toEqual([ + "claude", + "codex", + "gemini", + "opencode", + ]); + }); +}); + +describe("GET /api/v1/agents/:id/harness/queue", () => { + it("returns an empty queue for a running agent and 404s for an unknown one", async () => { + const empty = await authedGet(`/api/v1/agents/${agentId}/harness/queue`); + expect(empty.statusCode).toBe(200); + expect(empty.json()).toEqual({ queued: [] }); + const missing = await authedGet("/api/v1/agents/agt_nope/harness/queue"); + expect(missing.statusCode).toBe(404); + expect(missing.json().error).toBe("Agent not found."); + }); + + it("shapes a queued chat prompt with its chat text joined", async () => { + const app = Fastify(); + const chat = await ctx.pool.query<{ id: string }>( + `INSERT INTO agent_chat_messages + (id, agent_id, author_kind, kind, text, attachments, delivered) + VALUES (gen_random_uuid(), $1, 'user', 'reply', 'queued please', + '[]'::jsonb, NULL) + RETURNING id`, + [agentId] + ); + const chatId = chat.rows[0].id; + await registerAgentHarnessRoutes(app, { + pool: ctx.pool, + harness: { + getConfigOptions: () => null, + getSessionStartedAt: () => null, + setConfigOption: async () => [], + getCommands: () => null, + listQueued: () => [ + { + id: chatId, + source: { source: "chat", chatMessageId: chatId }, + createdAt: "2026-09-08T10:00:00.000Z", + }, + { + id: "q_2", + source: { + source: "agent", + senderId: "agt_other", + senderName: "Reviewer", + text: "take a look", + }, + createdAt: "2026-09-08T10:00:01.000Z", + }, + ], + sendQueuedNow: async () => false, + removeQueued: () => false, + interrupt: async () => false, + }, + }); + const res = await app.inject({ + method: "GET", + url: `/api/v1/agents/${agentId}/harness/queue`, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + queued: [ + { + id: chatId, + source: "chat", + text: "queued please", + chatMessageId: chatId, + attachments: [], + createdAt: "2026-09-08T10:00:00.000Z", + }, + { + id: "q_2", + source: "agent", + text: "take a look", + senderAgentId: "agt_other", + senderName: "Reviewer", + attachments: [], + createdAt: "2026-09-08T10:00:01.000Z", + }, + ], + }); + await app.close(); + }); +}); diff --git a/apps/server/test/harness-stream-recorder.test.ts b/apps/server/test/harness-stream-recorder.test.ts new file mode 100644 index 000000000..08f9f0727 --- /dev/null +++ b/apps/server/test/harness-stream-recorder.test.ts @@ -0,0 +1,663 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import type { DriverEvent } from "../src/agents/harness/driver.js"; +import { + boundOutput, + inferToolKind, + StreamRecorder, + TEXT_MAX_BYTES, +} from "../src/agents/harness/stream-recorder.js"; +import { StreamStore } from "../src/agents/harness/stream-store.js"; +import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; + +let pool: Pool; +let store: StreamStore; +const A = "agt_rec_a"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); + store = new StreamStore(pool); + await pool.query( + `INSERT INTO agents (id, name, cwd, status) VALUES ($1, 'R', '/tmp', 'running')`, + [A] + ); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +beforeEach(async () => { + await pool.query("DELETE FROM agent_stream_events"); +}); + +const chunk = (text: string): DriverEvent => ({ + type: "update", + agentId: A, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }, +}); + +const thought = (text: string): DriverEvent => ({ + type: "update", + agentId: A, + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text }, + }, +}); + +describe("StreamRecorder", () => { + it("accumulates chunks into one assistant row and settles it at turn end", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ type: "turn", agentId: A, state: "started", text: "x" }); + await rec.handle(chunk("Hel")); + await rec.handle(chunk("lo")); + await rec.flush(A); + const open = await store.list(A, 10); + expect(open[0].payload).toEqual({ text: "Hello", streaming: true }); + await rec.handle({ + type: "turn", + agentId: A, + state: "settled", + stopReason: "end_turn", + }); + const rows = (await store.list(A, 10)).filter((r) => r.kind !== "turn"); + expect(rows).toHaveLength(1); + expect(rows[0].payload).toEqual({ text: "Hello", streaming: false }); + }); + + it("starts a new assistant row after a tool call interrupts the text", async () => { + const rec = new StreamRecorder(store); + await rec.handle(chunk("one")); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call", + toolCallId: "c1", + title: "Read x", + kind: "read", + status: "pending", + locations: [{ path: "/w/x" }], + content: [], + }, + }); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "c1", + status: "completed", + content: [{ type: "diff", path: "/w/x", oldText: "a", newText: "b" }], + }, + }); + await rec.handle(chunk("two")); + await rec.flush(A); + // Updates with no prompted turn open a turn of their own (a goal round); + // this test is about the text rows, so it looks past that one. + const rows = (await store.list(A, 10)) + .reverse() + .filter((r) => r.kind !== "turn"); + expect(rows.map((r) => r.kind)).toEqual([ + "assistant", + "tool_call", + "assistant", + ]); + expect(rows[0].payload).toEqual({ text: "one", streaming: false }); + expect(rows[1].key).toBe("c1"); + expect(rows[1].payload).toEqual({ + toolKind: "read", + title: "Read x", + status: "completed", + locations: [{ path: "/w/x" }], + diff: { path: "/w/x", oldText: "a", newText: "b" }, + terminalOutput: null, + }); + expect(rows[2].payload).toEqual({ text: "two", streaming: true }); + }); + + it("keeps thoughts in their own rows, separate from assistant text", async () => { + const rec = new StreamRecorder(store); + await rec.handle(thought("plan")); + await rec.handle(thought("ning")); + await rec.handle(chunk("Done.")); + const rows = (await store.list(A, 10)) + .reverse() + .filter((r) => r.kind !== "turn"); + expect(rows.map((r) => [r.kind, r.payload.text])).toEqual([ + ["thought", "planning"], + ["assistant", "Done."], + ]); + }); + + it("captures terminal output from content blocks on a tool call update", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call", + toolCallId: "sh1", + title: "pnpm test", + kind: "execute", + status: "in_progress", + }, + }); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "sh1", + status: "completed", + content: [ + { type: "content", content: { type: "text", text: "12 passed\n" } }, + ], + }, + }); + const rows = await store.list(A, 10); + expect(rows[0].payload).toMatchObject({ + toolKind: "execute", + status: "completed", + terminalOutput: "12 passed\n", + }); + }); + + it("records a settled error and a crash as status rows", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ + type: "turn", + agentId: A, + state: "settled", + error: "no API key", + }); + await rec.handle({ + type: "exit", + agentId: A, + code: 1, + signal: null, + stderrTail: "boom", + expected: false, + }); + await rec.handle({ + type: "exit", + agentId: A, + code: 0, + signal: null, + stderrTail: "", + expected: false, + }); + // A stop Dispatch asked for is not a crash, whatever signal it took. + await rec.handle({ + type: "exit", + agentId: A, + code: null, + signal: "SIGTERM", + stderrTail: "", + expected: true, + }); + const rows = (await store.list(A, 10)).reverse(); + expect(rows.map((r) => r.payload.message)).toEqual([ + "no API key", + "the harness exited with code 1: boom", + ]); + }); + + it("renders tool locations relative to the agent's cwd", async () => { + const rec = new StreamRecorder(store); + rec.setCwd(A, "/w/repo"); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call", + toolCallId: "r1", + title: "Read", + kind: "read", + status: "completed", + locations: [ + { path: "/w/repo/src/index.ts", line: 3 }, + { path: "/etc/hosts" }, + ], + }, + }); + const rows = await store.list(A, 1); + expect(rows[0].payload.locations).toEqual([ + { path: "src/index.ts", line: 3 }, + { path: "/etc/hosts" }, + ]); + }); + + it("bounds an assistant message and marks it truncated", async () => { + const rec = new StreamRecorder(store); + const big = "x".repeat(TEXT_MAX_BYTES + 10); + await rec.handle(chunk("start")); + await rec.handle(chunk(big)); + await rec.handle(chunk("ignored after the cap")); + await rec.handle({ + type: "turn", + agentId: A, + state: "settled", + stopReason: "end_turn", + }); + const rows = await store.list(A, 1); + const payload = rows[0].payload as { + text: string; + truncated?: boolean; + streaming: boolean; + }; + expect(payload.truncated).toBe(true); + expect(payload.streaming).toBe(false); + expect(Buffer.byteLength(payload.text, "utf8")).toBeLessThanOrEqual( + TEXT_MAX_BYTES + 32 + ); + expect(payload.text).toContain("[truncated]"); + }); + + it("bounds both halves of a diff and marks the row truncated", async () => { + // Gemini CLI's write_file and OpenCode's write tool send the whole + // previous file as oldText, so an edit to a large file would otherwise + // put that file into the row, and every chat feed page and turns read + // pulls it back out again. + const rec = new StreamRecorder(store); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call", + toolCallId: "big", + title: "Write x", + kind: "edit", + status: "completed", + content: [ + { + type: "diff", + path: "/w/x", + oldText: "o".repeat(TEXT_MAX_BYTES + 10), + newText: "n", + }, + ], + }, + }); + const row = (await store.list(A, 10)).find((r) => r.key === "big"); + const payload = row?.payload as { + diff: { oldText: string; newText: string }; + truncated?: boolean; + }; + expect(payload.truncated).toBe(true); + expect(payload.diff.oldText).toContain("[truncated]"); + expect(Buffer.byteLength(payload.diff.oldText, "utf8")).toBeLessThanOrEqual( + TEXT_MAX_BYTES + 32 + ); + expect(payload.diff.newText).toBe("n"); + }); + + it("bounds terminal output head and tail", () => { + const out = boundOutput("a".repeat(100) + "b".repeat(100), 50); + expect(out.truncated).toBe(true); + expect(out.text.startsWith("a".repeat(25))).toBe(true); + expect(out.text.endsWith("b".repeat(25))).toBe(true); + expect(boundOutput("short", 50)).toEqual({ + text: "short", + truncated: false, + }); + }); + + it("infers a tool kind from the tool name when the engine sends none", () => { + expect(inferToolKind(undefined, "bash")).toBe("execute"); + expect(inferToolKind(undefined, "read")).toBe("read"); + expect(inferToolKind(undefined, "str_replace_editor")).toBe("edit"); + expect(inferToolKind(undefined, "grep")).toBe("search"); + expect(inferToolKind(undefined, "web_fetch")).toBe("fetch"); + expect(inferToolKind(undefined, "mcp__dispatch__dispatch_event")).toBe( + "other" + ); + expect(inferToolKind("delete", "bash")).toBe("delete"); + expect(inferToolKind("other", "bash")).toBe("execute"); + }); + + it("records a turn row at start and settles it in place", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ + type: "turn", + agentId: A, + state: "started", + text: "--- DISPATCH CHAT (id: 11111111-2222-4333-8444-555555555555) ---\nhi\n--- END DISPATCH CHAT ---", + }); + await rec.handle(chunk("reply")); + await rec.handle({ + type: "turn", + agentId: A, + state: "settled", + stopReason: "end_turn", + }); + const rows = (await store.list(A, 10)).reverse(); + expect(rows.map((r) => r.kind)).toEqual(["turn", "assistant"]); + expect(rows[0].payload).toMatchObject({ + state: "settled", + stopReason: "end_turn", + prompt: { + source: "chat", + chatMessageId: "11111111-2222-4333-8444-555555555555", + }, + }); + expect(typeof rows[0].payload.endedAt).toBe("string"); + }); + + it("records the error on a failed turn's row", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ + type: "turn", + agentId: A, + state: "started", + text: "plain", + }); + await rec.handle({ + type: "turn", + agentId: A, + state: "settled", + error: "no API key", + }); + const rows = (await store.list(A, 10)).reverse(); + expect(rows[0].payload).toMatchObject({ + state: "settled", + error: "no API key", + prompt: { source: "system", text: "plain" }, + }); + expect(rows[1].kind).toBe("status"); + }); + + it("writes a plan row for the live turn and replaces it on the next plan", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ type: "turn", agentId: A, state: "started", text: "x" }); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "plan", + entries: [ + { content: "read", status: "completed", priority: "high" }, + { content: "edit", status: "in_progress", priority: "medium" }, + ], + }, + }); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "plan_update", + plan: { + type: "items", + planId: "p1", + entries: [ + { content: "read", status: "completed", priority: "high" }, + { content: "edit", status: "completed", priority: "medium" }, + ], + }, + }, + }); + const plans = (await store.list(A, 10)).filter((r) => r.kind === "plan"); + expect(plans).toHaveLength(1); + expect(plans[0].payload).toEqual({ + entries: [ + { content: "read", status: "completed", priority: "high" }, + { content: "edit", status: "completed", priority: "medium" }, + ], + }); + }); + + it("ignores a plan_update that is a file or markdown plan", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ type: "turn", agentId: A, state: "started", text: "x" }); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "plan_update", + plan: { type: "markdown", planId: "p2", content: "# steps" } as never, + }, + }); + expect((await store.list(A, 10)).filter((r) => r.kind === "plan")).toEqual( + [] + ); + }); + + it("stores usage on the live turn row", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ type: "turn", agentId: A, state: "started", text: "x" }); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "usage_update", + used: 12_000, + size: 200_000, + cost: { amount: 0.42, currency: "USD" }, + }, + }); + const turn = (await store.list(A, 10)).find((r) => r.kind === "turn"); + expect(turn?.payload).toMatchObject({ + usage: { + used: 12_000, + size: 200_000, + cost: { amount: 0.42, currency: "USD" }, + }, + }); + await rec.handle({ + type: "update", + agentId: A, + update: { sessionUpdate: "usage_update", used: 13_000, size: 200_000 }, + }); + const again = (await store.list(A, 10)).find((r) => r.kind === "turn"); + expect(again?.payload).toMatchObject({ + usage: { used: 13_000, size: 200_000 }, + }); + expect( + (again?.payload as { usage: Record }).usage + ).not.toHaveProperty("cost"); + }); + + it("keeps the parent tool call id a nested call carries", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call", + toolCallId: "task_1", + title: "Task", + kind: "other", + status: "in_progress", + }, + }); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call", + toolCallId: "child_1", + title: "Read", + kind: "read", + status: "pending", + _meta: { claudeCode: { toolName: "Read", parentToolUseId: "task_1" } }, + }, + }); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "child_1", + status: "completed", + }, + }); + const child = await store.getByKey(A, "tool_call", "child_1"); + expect(child?.payload).toMatchObject({ + parentToolCallId: "task_1", + status: "completed", + }); + const parent = await store.getByKey(A, "tool_call", "task_1"); + expect(parent?.payload).not.toHaveProperty("parentToolCallId"); + }); +}); + +describe("StreamRecorder interrupted turns", () => { + it("settles the open turn with an error when the child dies mid-turn", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ + type: "turn", + agentId: A, + state: "started", + text: "go", + }); + await rec.handle(chunk("partial")); + await rec.handle({ + type: "exit", + agentId: A, + code: 1, + signal: null, + stderrTail: "boom", + expected: false, + }); + const rows = (await store.list(A, 10)).reverse(); + expect(rows[0].kind).toBe("turn"); + expect(rows[0].payload).toMatchObject({ + state: "settled", + error: "the harness exited before the turn settled", + }); + expect(typeof rows[0].payload.endedAt).toBe("string"); + expect(rows[1].payload).toMatchObject({ + text: "partial", + streaming: false, + }); + }); + + it("settles a turn cut off by Stop as cancelled", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ + type: "turn", + agentId: A, + state: "started", + text: "go", + }); + await rec.handle({ + type: "exit", + agentId: A, + code: 0, + signal: null, + stderrTail: "", + expected: true, + }); + const rows = (await store.list(A, 10)).reverse(); + expect(rows[0].payload).toMatchObject({ + state: "settled", + stopReason: "cancelled", + }); + expect(rows).toHaveLength(1); + }); + + it("reconcile settles what a previous process left open", async () => { + const rec = new StreamRecorder(store); + await rec.handle({ + type: "turn", + agentId: A, + state: "started", + text: "go", + }); + await rec.handle(chunk("half")); + // A fresh recorder, as after a server restart: no in-memory turn. + const fresh = new StreamRecorder(store); + expect(await fresh.reconcile(A)).toBe(1); + const rows = (await store.list(A, 10)).reverse(); + expect(rows[0].payload).toMatchObject({ + state: "settled", + error: "interrupted by restart", + }); + expect(rows[1].payload).toMatchObject({ streaming: false }); + }); +}); + +describe("StreamRecorder autonomous turns", () => { + const call = (id: string): DriverEvent => ({ + type: "update", + agentId: A, + update: { + sessionUpdate: "tool_call", + toolCallId: id, + title: "get_goal", + kind: "other", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "{}" } }], + }, + }); + const turnRows = async () => + ( + await pool.query<{ payload: Record }>( + `SELECT payload FROM agent_stream_events WHERE agent_id = $1 AND kind = 'turn' ORDER BY seq`, + [A] + ) + ).rows.map((r) => r.payload); + + it("opens a goal-round turn when the engine acts without a prompt and settles it once quiet", async () => { + const settled: string[] = []; + const rec = new StreamRecorder(store, { + autonomousIdleMs: 300, + onAutonomousSettled: (id) => settled.push(id), + }); + await rec.handle(call("c1")); + let turns = await turnRows(); + expect(turns).toHaveLength(1); + expect(turns[0]).toMatchObject({ state: "started", autonomous: true }); + expect(String((turns[0].prompt as { text: string }).text)).toContain( + "GOAL ROUND" + ); + // Activity keeps it open; quiet ends it. + await new Promise((r) => setTimeout(r, 100)); + await rec.handle(call("c2")); + await new Promise((r) => setTimeout(r, 100)); + expect((await turnRows())[0].state).toBe("started"); + await new Promise((r) => setTimeout(r, 500)); + turns = await turnRows(); + expect(turns[0]).toMatchObject({ + state: "settled", + stopReason: "end_turn", + }); + expect(settled).toEqual([A]); + }); + + it("settles an open goal-round turn before a prompted turn starts", async () => { + const rec = new StreamRecorder(store, { autonomousIdleMs: 10_000 }); + await rec.handle(call("c1")); + await rec.handle({ + type: "turn", + agentId: A, + state: "started", + text: "go", + }); + const turns = await turnRows(); + expect(turns.map((t) => [t.state, t.autonomous ?? false])).toEqual([ + ["settled", true], + ["started", false], + ]); + await rec.reconcile(A); + }); + + it("does not open a turn for a config change alone", async () => { + const rec = new StreamRecorder(store, { autonomousIdleMs: 10_000 }); + await rec.handle({ + type: "update", + agentId: A, + update: { + sessionUpdate: "config_option_update", + configOptions: [], + } as never, + }); + expect(await turnRows()).toHaveLength(0); + }); +}); diff --git a/apps/server/test/harness-stream-store.test.ts b/apps/server/test/harness-stream-store.test.ts new file mode 100644 index 000000000..d36b8d571 --- /dev/null +++ b/apps/server/test/harness-stream-store.test.ts @@ -0,0 +1,79 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import { StreamStore } from "../src/agents/harness/stream-store.js"; +import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; + +let pool: Pool; +let store: StreamStore; +const A = "agt_stream_a"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); + store = new StreamStore(pool); + await pool.query( + `INSERT INTO agents (id, name, cwd, status) VALUES ($1, 'Stream A', '/tmp', 'running')`, + [A] + ); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +beforeEach(async () => { + await pool.query("DELETE FROM agent_stream_events"); +}); + +describe("StreamStore", () => { + it("appends rows with a per-agent increasing seq", async () => { + const a = await store.append(A, "assistant", { text: "hi" }); + const b = await store.append(A, "status", { message: "x" }); + expect(b.seq).toBe(a.seq + 1); + expect(a.key).toBeNull(); + }); + + it("upserts a tool call by key without changing its seq", async () => { + const first = await store.upsertByKey(A, "tool_call", "call_1", { + status: "pending", + }); + const second = await store.upsertByKey(A, "tool_call", "call_1", { + status: "completed", + }); + expect(second.id).toBe(first.id); + expect(second.seq).toBe(first.seq); + expect(second.payload).toEqual({ status: "completed" }); + }); + + it("reads a keyed row without touching it", async () => { + await store.upsertByKey(A, "tool_call", "call_2", { status: "pending" }); + const row = await store.getByKey(A, "tool_call", "call_2"); + expect(row?.payload).toEqual({ status: "pending" }); + expect(await store.getByKey(A, "tool_call", "missing")).toBeNull(); + }); + + it("updates a payload in place", async () => { + const row = await store.append(A, "assistant", { text: "a" }); + await store.updatePayload(row.id, { text: "ab" }); + const rows = await store.list(A, 1); + expect(rows[0].id).toBe(row.id); + expect(rows[0].payload).toEqual({ text: "ab" }); + }); + + it("lists newest first, bounded by limit", async () => { + for (let i = 0; i < 5; i++) await store.append(A, "status", { i }); + const rows = await store.list(A, 3); + expect(rows.map((r) => r.payload.i)).toEqual([4, 3, 2]); + }); + + it("cascades with the agent", async () => { + await pool.query( + `INSERT INTO agents (id, name, cwd, status) VALUES ('agt_stream_gone', 'Gone', '/tmp', 'running')` + ); + await store.append("agt_stream_gone", "status", { message: "bye" }); + await pool.query(`DELETE FROM agents WHERE id = 'agt_stream_gone'`); + const rows = await store.list("agt_stream_gone", 10); + expect(rows).toEqual([]); + }); +}); diff --git a/apps/server/test/harness-supervisor.test.ts b/apps/server/test/harness-supervisor.test.ts new file mode 100644 index 000000000..c015df092 --- /dev/null +++ b/apps/server/test/harness-supervisor.test.ts @@ -0,0 +1,1271 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createJobMcpToken } from "../src/auth.js"; +import { HarnessDriver } from "../src/agents/harness/driver.js"; +import { + buildChildEnv, + HarnessSupervisor, + loginFailureMessage, + RESTART_PROMPT, +} from "../src/agents/harness/supervisor.js"; +import { createFakeAcpAgent, type FakeTurn } from "./helpers/fake-acp-agent.js"; + +const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}; + +let home = ""; +afterEach(async () => { + if (home) await rm(home, { recursive: true, force: true }); + home = ""; +}); + +async function build( + opts: { + turn?: FakeTurn; + cliSessionId?: string; + launchPrompt?: string; + /** The agent's stored model id; claude/default when omitted. */ + model?: string | null; + /** Config options the fake session publishes. */ + configOptions?: Parameters[0]["configOptions"]; + /** Slash commands the fake session announces after it opens. */ + commands?: Parameters[0]["commands"]; + /** The binary cannot be resolved: driver.start rejects. */ + startFails?: boolean; + /** What the newest turn row's error column says. */ + lastTurnError?: string | null; + /** When that turn ended; defaults to now. */ + lastTurnEndedAt?: Date; + /** The stored session cannot be resumed: the engine opens a fresh one. */ + resumeFails?: boolean; + /** The child ignores stdin EOF, SIGTERM and SIGKILL. */ + ignoreSignals?: boolean; + } = {} +) { + home = await mkdtemp(path.join(os.tmpdir(), "harness-sup-")); + const fake = createFakeAcpAgent({ + turn: opts.turn, + resumeFails: opts.resumeFails, + configOptions: opts.configOptions, + commands: opts.commands, + ignoreSignals: opts.ignoreSignals, + }); + const resolveBinary = async (bin: string) => { + if (opts.startFails) { + throw new Error(`${bin} was not found on the server's PATH`); + } + return bin; + }; + const driver = new HarnessDriver({ + spawn: () => fake.child, + resolveBinary, + logger, + }); + vi.mocked(logger.warn).mockClear(); + // A pool stand-in: every query takes a tick, and INSERTs hand back a row + // like Postgres would so the stream recorder's accumulation state works. + let nextId = 1; + const defaultQuery = async (sql: string, params?: unknown[]) => { + await new Promise((r) => setTimeout(r, 2)); + if (/INSERT INTO agent_stream_events/.test(sql)) { + const id = nextId++; + return { + rows: [ + { + id, + agent_id: params?.[0], + seq: id, + kind: params?.[1], + key: params?.[2], + payload: JSON.parse(String(params?.[3])), + created_at: new Date(), + updated_at: new Date(), + }, + ], + rowCount: 1, + }; + } + return { rows: [], rowCount: 0 }; + }; + const query = vi.fn(defaultQuery); + if (opts.lastTurnError !== undefined) { + const error = opts.lastTurnError; + // Only the settlement query is special-cased here; everything else + // (INSERTs included) falls through to the default stand-in above, or + // the recorder's writes fail silently and every other test that also + // passes `lastTurnError` would show no stream rows and a swallowed + // "harness event handling failed" warning. + query.mockImplementation(async (sql: string, params?: unknown[]) => { + if (/payload->>'error' AS error/.test(sql)) { + await new Promise((r) => setTimeout(r, 2)); + return { + rows: [ + { + error, + ended_at: (opts.lastTurnEndedAt ?? new Date()).toISOString(), + }, + ], + rowCount: 1, + }; + } + return defaultQuery(sql, params); + }); + } + const events: { type: string; message: string }[] = []; + const deps = { + pool: { query } as never, + config: { + claudeHarnessBin: "/bin/claude-agent-acp", + codexHarnessBin: "/bin/codex-acp", + geminiBin: "/bin/gemini", + opencodeBin: "/bin/opencode", + claudeBin: "/bin/claude", + codexBin: "/bin/codex", + dispatchBinDir: "/opt/dispatch/bin", + port: 1, + tls: null, + authToken: "secret", + mediaRoot: path.join(home, "media"), + }, + logger, + driver, + resolveBinary, + getAgent: vi.fn(async (id: string) => ({ + id, + type: "dispatch", + cwd: "/tmp/w", + mediaDir: null, + model: opts.model === undefined ? null : opts.model, + cliSessionId: opts.cliSessionId ?? null, + })) as never, + setCliSessionId: vi.fn(async () => {}), + setLatestEvent: vi.fn( + async (_id: string, input: { type: string; message: string }) => { + events.push(input); + } + ), + publishHarness: vi.fn(), + personaPromptFor: vi.fn(async () => "PERSONA TEXT"), + launchPromptFor: vi.fn(async () => opts.launchPrompt ?? null), + listRunningAgentIds: vi.fn(async () => [] as string[]), + markStartFailed: vi.fn(async () => {}), + }; + const sup = new HarnessSupervisor(deps); + return { fake, deps, events, sup, query }; +} + +describe("HarnessSupervisor", () => { + it("start records the session id, delivers the persona via _meta, and marks idle", async () => { + const { sup, deps, fake, events } = await build(); + await sup.start("agt_1"); + expect(deps.setCliSessionId).toHaveBeenCalledWith("agt_1", "sess_1"); + expect(fake.seen.newSession[0].cwd).toBe("/tmp/w"); + expect(fake.seen.newSession[0].mcpServers?.[0]).toMatchObject({ + type: "http", + name: "dispatch", + url: "http://127.0.0.1:1/api/mcp/agt_1", + }); + expect(fake.seen.newSession[0]._meta).toEqual({ + systemPrompt: { append: "PERSONA TEXT" }, + }); + expect(events.at(-1)).toEqual({ + type: "idle", + message: "Harness session started.", + }); + expect(sup.isRunning("agt_1")).toBe(true); + await sup.stop("agt_1"); + }); + + it("resumes a stored session id", async () => { + const { sup, fake, events } = await build({ cliSessionId: "sess_old" }); + await sup.start("agt_1"); + expect(fake.seen.resumeSession[0]?.sessionId).toBe("sess_old"); + expect(events.at(-1)?.message).toBe("Harness session resumed."); + await sup.stop("agt_1"); + }); + + it("says the engine could not resume when it falls back to a fresh session", async () => { + const { sup, events } = await build({ + cliSessionId: "sess_old", + resumeFails: true, + }); + await sup.start("agt_1"); + // Not a first start: the stored session came back as a fresh one, so + // the engine's own history is gone even though Dispatch keeps the turns. + expect(events.at(-1)?.message).toBe( + "Session restarted; this engine cannot resume, so Dispatch keeps the turns." + ); + await sup.stop("agt_1"); + }); + + it("prompt marks working, then idle when the turn settles, and publishes the chat", async () => { + const { sup, events, deps, query } = await build({ + turn: async (_p, emit) => { + await emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "ok" }, + }); + return "end_turn"; + }, + }); + await sup.start("agt_1"); + await sup.prompt("agt_1", "go"); + expect(events.map((e) => e.type)).toEqual(["idle", "working", "idle"]); + expect(deps.publishHarness).toHaveBeenCalledWith("agt_1", true); + // The stream recorder wrote through the pool. + expect(query).toHaveBeenCalled(); + await sup.stop("agt_1"); + }); + + it("coalesces the publishes streamed updates drive, and publishes a settled turn at once", async () => { + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { sup, deps } = await build({ + turn: async (_p, emit) => { + for (let i = 0; i < 10; i += 1) { + await emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `chunk ${i}` }, + }); + } + await gate; + return "end_turn"; + }, + }); + await sup.start("agt_1"); + vi.useFakeTimers(); + try { + deps.publishHarness.mockClear(); + const turn = sup.prompt("agt_1", "go"); + // The turn's start publishes at once. The ten chunks behind it wait on + // one trailing timer instead of taking a frame each. + await vi.advanceTimersByTimeAsync(50); + expect(deps.publishHarness.mock.calls).toEqual([["agt_1", true]]); + await vi.advanceTimersByTimeAsync(60); + expect(deps.publishHarness.mock.calls).toEqual([ + ["agt_1", true], + ["agt_1"], + ]); + release(); + await vi.advanceTimersByTimeAsync(20); + await turn; + expect(deps.publishHarness.mock.calls.at(-1)).toEqual(["agt_1", true]); + // Nothing is left on a timer behind the settled turn. + const total = deps.publishHarness.mock.calls.length; + await vi.advanceTimersByTimeAsync(300); + expect(deps.publishHarness.mock.calls.length).toBe(total); + } finally { + vi.useRealTimers(); + } + await sup.stop("agt_1"); + }); + + it("prompt failure surfaces as idle with the error message", async () => { + const { sup, events } = await build({ + turn: async () => { + throw new Error("no API key for provider route"); + }, + }); + await sup.start("agt_1"); + await sup.prompt("agt_1", "go"); + expect(events.at(-1)).toMatchObject({ + type: "idle", + message: expect.stringContaining("no API key"), + }); + await sup.stop("agt_1"); + }); + + it("refuses to start a non-Dispatch Harness agent", async () => { + const { sup, deps } = await build(); + deps.getAgent.mockResolvedValueOnce({ + id: "agt_c", + type: "claude", + cwd: "/tmp", + model: null, + cliSessionId: null, + } as never); + await expect(sup.start("agt_c")).rejects.toThrow( + /not a Dispatch Harness agent/ + ); + }); + + it("handles a burst of stream events in order, one writer per agent", async () => { + const { sup, query, deps } = await build({ + turn: async (_p, emit) => { + // Fire without awaiting: the driver sees these back to back. + const chunk = (text: string) => + emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }); + void chunk("a"); + void chunk("b"); + void emit({ + sessionUpdate: "tool_call", + toolCallId: "t1", + title: "Read x", + kind: "read", + status: "completed", + }); + await chunk("c"); + return "end_turn"; + }, + }); + await sup.start("agt_1"); + await sup.prompt("agt_1", "go"); + const writes = query.mock.calls.map( + ([sql, params]) => [String(sql).trim().slice(0, 6), params] as const + ); + const inserted = writes + .filter(([op]) => op === "INSERT") + .map(([, params]) => (params as unknown[])[1]) + .filter((kind) => kind !== "turn"); + // "a" opens the assistant row, "b" appends to it, the tool call closes + // it, "c" opens a second row: exactly three inserts, in stream order. + expect(inserted).toEqual(["assistant", "tool_call", "assistant"]); + const finalTexts = writes + .filter(([op]) => op === "UPDATE") + // Row updates carry the payload as $2; the reconcile sweep does not. + .filter(([, params]) => typeof (params as unknown[])[1] === "string") + .map(([, params]) => JSON.parse(String((params as unknown[])[1])).text) + .filter((text) => typeof text === "string"); + expect(finalTexts.at(-1)).toBe("c"); + expect(finalTexts).toContain("ab"); + expect(deps.logger.warn).not.toHaveBeenCalledWith( + expect.anything(), + "harness event handling failed" + ); + await sup.stop("agt_1"); + }); + + it("runs overlapping prompts one at a time, in order, and reports idle once", async () => { + const { sup, fake, events } = await build({ + turn: async (p, emit) => { + await new Promise((r) => setTimeout(r, 15)); + await emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `echo ${p}` }, + }); + return "end_turn"; + }, + }); + await sup.start("agt_1"); + const first = sup.enqueuePrompt("agt_1", "one"); + const second = sup.enqueuePrompt("agt_1", "two"); + expect(sup.isBusy("agt_1")).toBe(true); + await first.started; + let secondStarted = false; + void second.started.then(() => { + secondStarted = true; + }); + await new Promise((r) => setTimeout(r, 5)); + expect(secondStarted).toBe(false); + await first.settled; + await second.settled; + expect(fake.seen.prompts).toEqual(["one", "two"]); + expect(events.map((e) => e.type)).toEqual([ + "idle", + "working", + "working", + "idle", + ]); + expect(sup.isBusy("agt_1")).toBe(false); + await sup.stop("agt_1"); + }); + + it("restores running agents at boot and marks the ones that fail", async () => { + const { sup, deps, fake } = await build(); + deps.listRunningAgentIds.mockResolvedValue(["agt_1", "agt_2"]); + deps.getAgent.mockImplementation(async (id: string) => + id === "agt_2" + ? { + id, + type: "claude", + cwd: "/tmp", + mediaDir: null, + model: null, + cliSessionId: null, + } + : { + id, + type: "dispatch", + cwd: "/tmp/w", + mediaDir: null, + model: null, + cliSessionId: null, + } + ); + const result = await sup.restoreRunning(); + expect(result).toEqual({ restored: ["agt_1"], failed: ["agt_2"] }); + expect(deps.markStartFailed).toHaveBeenCalledWith( + "agt_2", + expect.stringContaining("not a Dispatch Harness agent") + ); + expect(fake.seen.newSession).toHaveLength(1); + await sup.stopAll(); + expect(sup.isRunning("agt_1")).toBe(false); + }); +}); + +describe("HarnessSupervisor engines", () => { + it("claude: persona travels in _meta and the first prompt is the launch post alone", async () => { + const { sup, fake } = await build({ + launchPrompt: + "--- DISPATCH CHAT (id: 11111111-1111-1111-1111-111111111111) ---\nhello", + }); + await sup.start("agt_c"); + await new Promise((r) => setTimeout(r, 20)); + expect(fake.seen.newSession[0]._meta).toEqual({ + systemPrompt: { append: "PERSONA TEXT" }, + }); + expect(fake.seen.prompts[0]).toMatch(/^--- DISPATCH CHAT/); + await sup.stop("agt_c"); + }); + + it("codex: the persona is the leading block of the first prompt of a fresh session", async () => { + const { sup, fake } = await build({ + model: "codex/default", + launchPrompt: + "--- DISPATCH CHAT (id: 22222222-2222-2222-2222-222222222222) ---\nhello", + }); + await sup.start("agt_x"); + await new Promise((r) => setTimeout(r, 20)); + expect(fake.seen.newSession[0]._meta).toBeUndefined(); + expect(fake.seen.prompts[0]).toBe( + "PERSONA TEXT\n\n--- DISPATCH CHAT (id: 22222222-2222-2222-2222-222222222222) ---\nhello" + ); + // The second prompt carries no persona. + await sup.prompt("agt_x", "again"); + expect(fake.seen.prompts[1]).toBe("again"); + await sup.stop("agt_x"); + }); + + it("codex: a resumed session gets no persona prefix", async () => { + const { sup, fake } = await build({ + model: "codex/default", + cliSessionId: "sess_old", + // A turn already ran on this session, so its history already has the + // persona; only that (not merely `resumed`) should suppress it. + lastTurnError: null, + }); + await sup.start("agt_r"); + await sup.prompt("agt_r", "continue"); + expect(fake.seen.prompts).toEqual(["continue"]); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.anything(), + "harness event handling failed" + ); + await sup.stop("agt_r"); + }); + + it("codex: a resumed session that never ran a turn still gets the persona prefix", async () => { + // The process stopped (or crashed) between opening the session and its + // first prompt: the resume has nothing in its history to carry the + // persona, so it must still be delivered: same as a fresh session, the + // launch prompt is resent as the first turn. + const { sup, fake } = await build({ + model: "codex/default", + cliSessionId: "sess_old", + launchPrompt: + "--- DISPATCH CHAT (id: 33333333-3333-3333-3333-333333333333) ---\nhello", + }); + await sup.start("agt_r"); + await new Promise((r) => setTimeout(r, 20)); + expect(fake.seen.prompts[0]).toBe( + "PERSONA TEXT\n\n--- DISPATCH CHAT (id: 33333333-3333-3333-3333-333333333333) ---\nhello" + ); + // The second prompt carries no persona: it was already consumed. + await sup.prompt("agt_r", "continue"); + expect(fake.seen.prompts[1]).toBe("continue"); + await sup.stop("agt_r"); + }); + + it("gemini: sets the yolo mode and never asks the session for a model option", async () => { + const { sup, fake } = await build({ model: "gemini/gemini-2.5-pro" }); + await sup.start("agt_g"); + expect(fake.seen.setMode).toEqual([ + { sessionId: "sess_1", modeId: "yolo" }, + ]); + expect(fake.seen.setConfig).toEqual([]); + // A non-default model with no config option to apply it through would + // normally warn; the modelFixedAtLaunch guard is what keeps this quiet. + expect(logger.warn).not.toHaveBeenCalledWith( + expect.anything(), + expect.stringMatching(/publishes no model option/) + ); + await sup.stop("agt_g"); + }); + + it("applies a non-default model through the session's model option", async () => { + const { sup, fake } = await build({ + model: "opencode/anthropic/claude-sonnet-5", + configOptions: [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "openai/gpt-5.5", + options: [ + { value: "openai/gpt-5.5", name: "GPT-5.5" }, + { value: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5" }, + ], + }, + ], + }); + await sup.start("agt_o"); + expect(fake.seen.setConfig).toEqual([ + { + sessionId: "sess_1", + configId: "model", + value: "anthropic/claude-sonnet-5", + }, + ]); + await sup.stop("agt_o"); + }); + + it("warns and keeps the default when a non-default model meets no model option", async () => { + const { sup, fake } = await build({ model: "codex/gpt-5.6-sol" }); + await sup.start("agt_w"); + expect(fake.seen.setConfig).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "agt_w", model: "gpt-5.6-sol" }), + expect.stringMatching(/publishes no model option/) + ); + await sup.stop("agt_w"); + }); + + it("rejects an agent whose model has no engine prefix", async () => { + const { sup } = await build({ model: "gpt-5.6-sol" }); + await expect(sup.start("agt_bad")).rejects.toThrow(/engine\/model/); + }); + + it("records the engine, not just the model, on the token-usage row", async () => { + const { sup, query } = await build({ + model: "codex/gpt-5.6-sol", + turn: async () => ({ + stopReason: "end_turn", + usage: { + totalTokens: 30, + inputTokens: 20, + outputTokens: 10, + thoughtTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + }), + }); + await sup.start("agt_1"); + await sup.prompt("agt_1", "go"); + const usageInsert = query.mock.calls.find(([sql]) => + /INSERT INTO agent_token_usage/.test(String(sql)) + ); + expect(usageInsert?.[1]?.[2]).toBe("codex/gpt-5.6-sol"); + await sup.stop("agt_1"); + }); + + it("serves the commands the engine advertised", async () => { + const { sup } = await build({ + commands: [ + { name: "review", description: "Review the branch", input: null }, + { name: "compact", description: "Compact", input: { hint: "focus" } }, + ], + }); + expect(sup.getCommands("agt_none")).toBeNull(); + await sup.start("agt_1"); + await new Promise((r) => setTimeout(r, 20)); + expect(sup.getCommands("agt_1")).toEqual([ + { name: "review", description: "Review the branch" }, + { name: "compact", description: "Compact", input: { hint: "focus" } }, + ]); + await sup.stop("agt_1"); + }); +}); + +describe("HarnessSupervisor launch prompt", () => { + it("sends the launch prompt as the first turn of a fresh session", async () => { + const { sup, fake, events } = await build({ launchPrompt: "do the thing" }); + await sup.start("agt_1"); + await new Promise((r) => setTimeout(r, 20)); + expect(fake.seen.prompts).toEqual(["do the thing"]); + expect(events.map((e) => e.type)).toEqual(["idle", "working", "idle"]); + await sup.stop("agt_1"); + }); + + it("does not resend it on resume once a turn has run", async () => { + const { sup, fake } = await build({ + launchPrompt: "do the thing", + cliSessionId: "sess_old", + // A turn already ran on this session, so the launch prompt already + // went out; only a resume that never ran a turn resends it. + lastTurnError: null, + }); + await sup.start("agt_1"); + await new Promise((r) => setTimeout(r, 20)); + expect(fake.seen.prompts).toEqual([]); + await sup.stop("agt_1"); + }); + + it("resends it on a resume that never ran a turn", async () => { + // The process stopped (or crashed) between opening the session and its + // first prompt: the resume has no turn behind it, so the launch prompt + // never went out and must be sent now. + const { sup, fake } = await build({ + launchPrompt: "do the thing", + cliSessionId: "sess_old", + }); + await sup.start("agt_1"); + await new Promise((r) => setTimeout(r, 20)); + expect(fake.seen.prompts).toEqual(["do the thing"]); + await sup.stop("agt_1"); + }); +}); + +describe("buildChildEnv", () => { + const base = { + PATH: "/usr/bin", + HOME: "/home/u", + SSH_AUTH_SOCK: "/tmp/agent.sock", + HTTPS_PROXY: "http://proxy:3128", + OPENAI_API_KEY: "sk-test", + CODEX_API_KEY: "sk-codex", + ANTHROPIC_API_KEY: "sk-anthropic", + GEMINI_API_KEY: "gem-key", + DATABASE_URL: "postgres://secret", + PGPASSWORD: "hunter2", + DISPATCH_SESSION_PREFIX: "dispatch", + TLS_CA: "/etc/ca.pem", + }; + + it("passes the login-shell environment through and drops Dispatch internals", () => { + const env = buildChildEnv({ + agentId: "agt_1", + mediaDir: "/media/agt_1", + config: { port: 6767, tls: null, dispatchBinDir: "/opt/dispatch/bin" }, + base, + }); + expect(env.SSH_AUTH_SOCK).toBe("/tmp/agent.sock"); + expect(env.HTTPS_PROXY).toBe("http://proxy:3128"); + expect(env.HOME).toBe("/home/u"); + // Each engine authenticates through its own host login, so a provider + // key left in the service environment must not reach the child. + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.CODEX_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + // Except this one: it is one of Gemini CLI's own logins. + expect(env.GEMINI_API_KEY).toBe("gem-key"); + expect(env.DATABASE_URL).toBeUndefined(); + expect(env.PGPASSWORD).toBeUndefined(); + expect(env.DISPATCH_SESSION_PREFIX).toBeUndefined(); + expect(env.DISPATCH_AGENT_ID).toBe("agt_1"); + expect(env.DISPATCH_MEDIA_DIR).toBe("/media/agt_1"); + expect(env.DISPATCH_PORT).toBe("6767"); + expect(env.DISPATCH_SCHEME).toBe("http"); + expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined(); + }); + + it("exports the TLS CA for the loopback https MCP URL", () => { + const env = buildChildEnv({ + agentId: "agt_1", + mediaDir: "/m", + config: { + port: 6767, + tls: { cert: Buffer.from(""), key: Buffer.from("") }, + dispatchBinDir: "/opt/dispatch/bin", + }, + base, + }); + expect(env.DISPATCH_SCHEME).toBe("https"); + expect(env.NODE_EXTRA_CA_CERTS).toBe("/etc/ca.pem"); + expect(env.TLS_CA).toBeUndefined(); + }); + it("prepends the same PATH entries the pane launch does, deduped", () => { + // The service units pin PATH to + // /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin, so + // without this the child cannot find an engine installed with + // `npm install -g --prefix ~/.local`, which is what the runbook tells + // operators to do, and resolveBinary reads this same PATH. + const env = buildChildEnv({ + agentId: "agt_1", + mediaDir: "/m", + config: { port: 6767, tls: null, dispatchBinDir: "/opt/dispatch/bin" }, + base: { ...base, PATH: "/home/u/.local/bin:/usr/bin" }, + }); + expect(env.PATH).toBe("/opt/dispatch/bin:/home/u/.local/bin:/usr/bin"); + }); + + it("pins the Claude Bash tool's working directory, and only for Claude", () => { + const forEngine = (engine: "claude" | "codex") => + buildChildEnv({ + agentId: "agt_1", + mediaDir: "/m", + config: { port: 6767, tls: null, dispatchBinDir: "/opt/dispatch/bin" }, + base, + engine, + }); + expect(forEngine("claude").CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR).toBe( + "1" + ); + expect( + forEngine("codex").CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR + ).toBeUndefined(); + }); +}); + +describe("HarnessSupervisor lifecycle edges", () => { + it("keeps a terminal status the agent set during the turn", async () => { + let depsRef: { + setLatestEvent: ( + id: string, + e: { type: string; message: string } + ) => Promise; + } | null = null; + const { sup, deps, events, fake } = await build({ + turn: async () => { + // The agent's own dispatch_event done, from inside the turn. + await depsRef!.setLatestEvent("agt_1", { + type: "done", + message: "Review submitted", + }); + return "end_turn"; + }, + }); + depsRef = deps; + const stamp = (n: number) => + new Date(Date.UTC(2026, 0, 1, 0, 0, n)).toISOString(); + deps.getAgent.mockImplementation(async (id: string) => ({ + id, + type: "dispatch", + cwd: "/tmp/w", + mediaDir: null, + model: null, + cliSessionId: null, + latestEvent: events.length + ? { + ...events[events.length - 1], + updatedAt: stamp(events.length), + metadata: null, + } + : null, + })); + await sup.start("agt_1"); + events.length = 0; + await sup.prompt("agt_1", "review this"); + expect(events.map((e) => e.type)).toEqual(["working", "done"]); + await sup.stopAll(); + expect(fake.seen.prompts).toHaveLength(1); + }); + + it("marks an unexpected exit, code 0 included, through markExited", async () => { + const { sup, deps, fake } = await build(); + const markExited = vi.fn(async () => {}); + (deps as { markExited?: typeof markExited }).markExited = markExited; + await sup.start("agt_1"); + // The engine quits on its own: the child exits without Dispatch asking. + fake.child.kill("SIGTERM"); + await vi.waitFor(() => expect(markExited).toHaveBeenCalled()); + expect(markExited).toHaveBeenCalledWith( + "agt_1", + expect.stringContaining("The engine exited") + ); + expect(sup.isRunning("agt_1")).toBe(false); + }); + + it("fails to start when the engine binary cannot be resolved", async () => { + const { sup } = await build({ startFails: true }); + await expect(sup.start("agt_1")).rejects.toThrow( + /was not found on the server's PATH/ + ); + }); + + it("settles rows a previous process left open before starting", async () => { + const { sup, query } = await build(); + await sup.start("agt_1"); + const settle = query.mock.calls.find(([sql]) => + /payload->>'state' = 'started'/.test(String(sql)) + ); + expect(settle?.[1]?.[0]).toBe("agt_1"); + await sup.stopAll(); + }); +}); + +describe("HarnessSupervisor job runs", () => { + it("attaches the job MCP route and token for an agent running a job", async () => { + const { sup, deps, fake } = await build(); + ( + deps as { activeJobRunIdFor?: (id: string) => Promise } + ).activeJobRunIdFor = vi.fn(async () => "run_42"); + await sup.start("agt_1"); + const server = fake.seen.newSession[0]?.mcpServers?.[0] as { + url: string; + headers: { name: string; value: string }[]; + }; + expect(server.url).toMatch(/\/api\/mcp\/jobs\/run_42\/agt_1$/); + expect(server.headers[0].value).toBe( + `Bearer ${createJobMcpToken("secret", "run_42", "agt_1")}` + ); + expect(deps.personaPromptFor).toHaveBeenCalledWith( + expect.objectContaining({ id: "agt_1" }), + "run_42" + ); + await sup.stopAll(); + }); +}); + +describe("HarnessSupervisor message queue", () => { + const CHAT_ID = "0f3d2a8e-6c4b-4c1e-9b7a-1d2e3f4a5b6c"; + const envelope = (text: string) => + `--- DISPATCH CHAT (id: ${CHAT_ID}) ---\n${text}\n--- END DISPATCH CHAT ---`; + + it("lists what waits behind the running turn, in order, and drains it", async () => { + const { sup, fake, deps } = await build({ + turn: async () => { + await new Promise((r) => setTimeout(r, 15)); + return "end_turn"; + }, + }); + await sup.start("agt_1"); + const first = sup.enqueuePrompt("agt_1", "one"); + sup.enqueuePrompt("agt_1", envelope("two")); + const third = sup.enqueuePrompt("agt_1", "three"); + await first.started; + const queued = sup.listQueued("agt_1"); + expect(queued.map((q) => q.source)).toEqual([ + { source: "chat", chatMessageId: CHAT_ID }, + { source: "system", text: "three" }, + ]); + // A chat message queues under its own id, so the view can act on it. + expect(queued[0].id).toBe(CHAT_ID); + expect(queued[1].id).toMatch(/^q_/); + expect(queued[0].createdAt <= queued[1].createdAt).toBe(true); + // The feed is told, so the view lists the wait without a stream write. + expect(deps.publishHarness).toHaveBeenCalledWith("agt_1"); + await third.settled; + expect(sup.listQueued("agt_1")).toEqual([]); + expect(fake.seen.prompts).toEqual(["one", envelope("two"), "three"]); + expect(sup.isBusy("agt_1")).toBe(false); + await sup.stop("agt_1"); + }); + + it("removes a queued prompt: it never runs and its start rejects", async () => { + const { sup, fake, events } = await build({ + turn: async () => { + await new Promise((r) => setTimeout(r, 15)); + return "end_turn"; + }, + }); + await sup.start("agt_1"); + const first = sup.enqueuePrompt("agt_1", "one"); + const second = sup.enqueuePrompt("agt_1", envelope("two")); + await first.started; + expect(sup.removeQueued("agt_1", CHAT_ID)).toBe(true); + expect(sup.removeQueued("agt_1", CHAT_ID)).toBe(false); + await expect(second.started).rejects.toThrow(/removed/i); + await second.settled; + await first.settled; + expect(fake.seen.prompts).toEqual(["one"]); + // With nothing left behind it, the first turn settles the agent idle. + expect(events.map((e) => e.type)).toEqual(["idle", "working", "idle"]); + expect(sup.isBusy("agt_1")).toBe(false); + await sup.stop("agt_1"); + }); + + it("send-now moves a prompt to the front and interrupts the running turn", async () => { + const { sup, fake } = await build({ + turn: async (_p, _emit, _ask, signal) => { + await new Promise((resolve) => { + const timer = setTimeout(resolve, 400); + signal.addEventListener("abort", () => { + clearTimeout(timer); + resolve(); + }); + }); + return signal.aborted ? "cancelled" : "end_turn"; + }, + }); + await sup.start("agt_1"); + const first = sup.enqueuePrompt("agt_1", "one"); + const second = sup.enqueuePrompt("agt_1", "two"); + const third = sup.enqueuePrompt("agt_1", envelope("three")); + await first.started; + expect(await sup.sendQueuedNow("agt_1", CHAT_ID)).toBe(true); + expect(await sup.sendQueuedNow("agt_1", "nope")).toBe(false); + await third.started; + expect(sup.listQueued("agt_1").map((q) => q.source)).toEqual([ + { source: "system", text: "two" }, + ]); + await second.settled; + expect(fake.seen.cancels).toBe(1); + expect(fake.seen.prompts).toEqual(["one", envelope("three"), "two"]); + await sup.stop("agt_1"); + }); + + it("shutdown leaves a queued chat message pending for the next boot", async () => { + const CHAT = "0f3d2a8e-6c4b-4c1e-9b7a-1d2e3f4a5b6c"; + const { sup, fake } = await build({ + turn: async (_p, _emit, _ask, signal) => { + await new Promise((resolve) => { + signal.addEventListener("abort", () => resolve()); + setTimeout(resolve, 2000); + }); + return "cancelled"; + }, + }); + await sup.start("agt_1"); + const first = sup.enqueuePrompt("agt_1", "one"); + const chat = sup.enqueuePrompt( + "agt_1", + `--- DISPATCH CHAT (id: ${CHAT}) ---\nlater\n--- END DISPATCH CHAT ---` + ); + const system = sup.enqueuePrompt("agt_1", "system note"); + await first.started; + let chatSettled: "pending" | "started" | "failed" = "pending"; + chat.started.then( + () => (chatSettled = "started"), + () => (chatSettled = "failed") + ); + await sup.stopAll(); + await expect(system.started).rejects.toThrow(/stopped/); + await chat.settled; + await new Promise((r) => setTimeout(r, 10)); + expect(chatSettled).toBe("pending"); + expect(fake.seen.prompts).toEqual(["one"]); + }); + + it("a chat message's start rejects when the engine cannot accept the prompt", async () => { + const { sup, fake } = await build(); + // No start(), so there is no live child and the driver's liveness guard + // rejects the prompt. ChatService records that rejection as + // delivered: false, the honest answer for a turn that never reached the + // engine, where resolving would claim the engine had it. Redelivery at + // the next boot is a separate thing: listPendingDeliveries takes only + // rows left at delivered IS NULL, which is what the shutdown path + // leaves behind, not this one. + const chat = sup.enqueuePrompt("agt_1", envelope("only")); + await expect(chat.started).rejects.toThrow(/not running/i); + await chat.settled; + expect(fake.seen.prompts).toEqual([]); + }); + + it("stop drops what is queued and fails their starts", async () => { + const { sup, fake } = await build({ + turn: async (_p, _emit, _ask, signal) => { + await new Promise((resolve) => { + const timer = setTimeout(resolve, 400); + signal.addEventListener("abort", () => { + clearTimeout(timer); + resolve(); + }); + }); + return "end_turn"; + }, + }); + await sup.start("agt_1"); + const first = sup.enqueuePrompt("agt_1", "one"); + const second = sup.enqueuePrompt("agt_1", "two"); + await first.started; + await sup.stop("agt_1"); + await expect(second.started).rejects.toThrow(/stopped/i); + await first.settled; + expect(fake.seen.prompts).toEqual(["one"]); + expect(sup.listQueued("agt_1")).toEqual([]); + expect(sup.isBusy("agt_1")).toBe(false); + }); +}); + +describe("HarnessSupervisor restart resilience", () => { + it("resumes an agent whose last turn the restart cut short", async () => { + const { sup, deps, fake } = await build({ + cliSessionId: "sess_old", + lastTurnError: "interrupted by restart", + }); + deps.listRunningAgentIds.mockResolvedValue(["agt_1"]); + await sup.restoreRunning(); + await vi.waitFor(() => expect(fake.seen.prompts).toEqual([RESTART_PROMPT])); + await sup.stopAll(); + }); + + it("stopAll SIGKILLs a child that ignores close, EOF and SIGTERM", async () => { + // The engine children hold full-access permissions and a live MCP token, + // and the installed unit uses KillMode=process, so one that outlives the + // shutdown is an orphan with both. The teardown ladder alone cannot be + // trusted to reach its own SIGKILL inside the bound, and process.exit() + // right after would drop a kill still in flight. + const { sup, fake, deps } = await build({ ignoreSignals: true }); + await sup.start("agt_1"); + vi.useFakeTimers(); + try { + const stopping = sup.stopAll(); + // Past the whole budget: reconcile, then close, EOF and SIGTERM steps, + // then the stop race's own ceiling. + await vi.advanceTimersByTimeAsync(30_000); + await stopping; + } finally { + vi.useRealTimers(); + } + expect(fake.signals).toContain("SIGKILL"); + expect(deps.driver.liveAgentIds()).toEqual([]); + }); + + it("starts nothing more once shutdown has begun", async () => { + const CHAT = "0f3d2a8e-6c4b-4c1e-9b7a-1d2e3f4a5b6c"; + const { sup, fake } = await build(); + await sup.start("agt_1"); + // stopAll() raises its flag synchronously, before it snapshots what is + // running, so a message that arrives inside the teardown window stays + // queued. flushQueued then leaves its chat row undelivered for the next + // boot, instead of the teardown cutting a turn it had just started. + const stopping = sup.stopAll(); + const chat = sup.enqueuePrompt( + "agt_1", + `--- DISPATCH CHAT (id: ${CHAT}) ---\nlater\n--- END DISPATCH CHAT ---` + ); + let chatSettled: "pending" | "started" | "failed" = "pending"; + chat.started.then( + () => (chatSettled = "started"), + () => (chatSettled = "failed") + ); + expect(sup.listQueued("agt_1").map((q) => q.id)).toEqual([CHAT]); + await stopping; + await chat.settled; + await new Promise((r) => setTimeout(r, 10)); + expect(chatSettled).toBe("pending"); + expect(fake.seen.prompts).toEqual([]); + }); + + it("does not resume when the cut is old, the agent is done, or the session is fresh", async () => { + for (const build_opts of [ + { + cliSessionId: "sess_old", + lastTurnError: "interrupted by restart", + lastTurnEndedAt: new Date(Date.now() - 2 * 60 * 60_000), + }, + { + cliSessionId: "sess_old", + lastTurnError: "interrupted by restart", + resumeFails: true, + }, + ]) { + const { sup, deps, fake } = await build(build_opts); + deps.listRunningAgentIds.mockResolvedValue(["agt_1"]); + await sup.restoreRunning(); + await new Promise((r) => setTimeout(r, 30)); + expect(fake.seen.prompts).toEqual([]); + await sup.stopAll(); + } + const { sup, deps, fake } = await build({ + cliSessionId: "sess_old", + lastTurnError: "interrupted by restart", + }); + // As in production: the record says "done" until start() writes + // "session resumed" over it, so the guard must read it before that. + let latest = { type: "done", message: "Review submitted", updatedAt: "x" }; + deps.setLatestEvent.mockImplementation( + async (_id: string, input: { type: string; message: string }) => { + latest = { ...input, updatedAt: "y" }; + } + ); + deps.getAgent.mockImplementation(async (id: string) => ({ + id, + type: "dispatch", + cwd: "/tmp/w", + mediaDir: null, + model: null, + cliSessionId: "sess_old", + latestEvent: latest, + })); + deps.listRunningAgentIds.mockResolvedValue(["agt_1"]); + await sup.restoreRunning(); + await new Promise((r) => setTimeout(r, 30)); + expect(fake.seen.prompts).toEqual([]); + await sup.stopAll(); + }); + + it("leaves an agent alone when its last turn ended on its own", async () => { + const { sup, deps, fake } = await build({ + cliSessionId: "sess_old", + lastTurnError: null, + }); + deps.listRunningAgentIds.mockResolvedValue(["agt_1"]); + await sup.restoreRunning(); + await new Promise((r) => setTimeout(r, 30)); + expect(fake.seen.prompts).toEqual([]); + await sup.stopAll(); + }); + + it("marks a running turn as interrupted by restart when shutting down", async () => { + const { sup, query, fake } = await build({ + turn: async (_p, _emit, _ask, signal) => { + await new Promise((resolve) => { + signal.addEventListener("abort", () => resolve()); + setTimeout(resolve, 2000); + }); + return "cancelled"; + }, + }); + await sup.start("agt_1"); + const first = sup.enqueuePrompt("agt_1", "long job"); + await first.started; + query.mockClear(); + await sup.stopAll(); + const settle = query.mock.calls.find(([sql]) => + /payload->>'state' = 'started'/.test(String(sql)) + ); + expect(settle?.[1]?.[0]).toBe("agt_1"); + expect(String(settle?.[1]?.[1])).toContain("interrupted by restart"); + expect(fake.seen.closes).toBe(1); + }); +}); + +describe("loginFailureMessage", () => { + it("names the engine for an auth_required code", () => { + const err = Object.assign(new Error("Authentication required"), { + code: -32000, + }); + expect(loginFailureMessage("codex", err)).toBe( + "Codex is not logged in on the server." + ); + }); + + it("is null for an unrelated error", () => { + expect(loginFailureMessage("codex", new Error("ENOENT"))).toBeNull(); + }); + + it("recognizes Claude's please-run-/login reply", () => { + expect(loginFailureMessage("claude", new Error("Please run /login"))).toBe( + "Claude Code is not logged in on the server." + ); + }); +}); + +describe("HarnessSupervisor login failure", () => { + /** A driver stub whose start() always rejects; only start() and onEvent() + * are exercised by the paths under test here. */ + function stubDriver(err: unknown): HarnessDriver { + return { + start: vi.fn().mockRejectedValue(err), + onEvent: vi.fn(), + } as unknown as HarnessDriver; + } + + it("start() rejects with the engine's login message on an auth_required failure", async () => { + const { deps } = await build({ model: "codex/default" }); + const err = Object.assign(new Error("Authentication required"), { + code: -32000, + }); + const sup = new HarnessSupervisor({ ...deps, driver: stubDriver(err) }); + await expect(sup.start("agt_1")).rejects.toThrow( + "Codex is not logged in on the server." + ); + }); + + it("boot restore marks a login failure with the engine's message", async () => { + const { deps } = await build({ model: "gemini/default" }); + deps.listRunningAgentIds.mockResolvedValue(["agt_g"]); + const err = new Error("Authentication required: run gemini"); + const sup = new HarnessSupervisor({ ...deps, driver: stubDriver(err) }); + const result = await sup.restoreRunning(); + expect(result.failed).toEqual(["agt_g"]); + expect(deps.markStartFailed).toHaveBeenCalledWith( + "agt_g", + "Gemini CLI is not logged in on the server." + ); + }); + + it("stops a Claude session that answers /login and reports through markExited", async () => { + const { sup, deps } = await build({ + turn: async (_prompt, emit) => { + await emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Please run /login to authenticate." }, + }); + return "end_turn"; + }, + }); + const markExited = vi.fn(async () => {}); + (deps as { markExited?: typeof markExited }).markExited = markExited; + const stopSpy = vi.spyOn(HarnessDriver.prototype, "stop"); + await sup.start("agt_1"); + await sup.prompt("agt_1", "hi"); + await vi.waitFor(() => expect(markExited).toHaveBeenCalled()); + expect(stopSpy).toHaveBeenCalledWith("agt_1"); + expect(markExited).toHaveBeenCalledWith( + "agt_1", + "Claude Code is not logged in on the server." + ); + stopSpy.mockRestore(); + }); + + it("leaves a Claude turn alone when the phrase does not open the reply", async () => { + // The anchor on its own, with no tool call to rule the answer out. + const { sup, deps } = await build({ + turn: async (_prompt, emit) => { + await emit({ + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "The runbook says to please run /login as the service user.", + }, + }); + return "end_turn"; + }, + }); + const markExited = vi.fn(async () => {}); + (deps as { markExited?: typeof markExited }).markExited = markExited; + const stopSpy = vi.spyOn(HarnessDriver.prototype, "stop"); + await sup.start("agt_1"); + await sup.prompt("agt_1", "what does the runbook say"); + // Restore before asserting: the spy is on the prototype, so a failure + // here would otherwise leak it into the next test. + const stops = stopSpy.mock.calls.length; + stopSpy.mockRestore(); + expect(markExited).not.toHaveBeenCalled(); + expect(stops).toBe(0); + expect(sup.isRunning("agt_1")).toBe(true); + await sup.stop("agt_1"); + }); + + it("leaves a Claude turn alone when the phrase is prose in a working turn", async () => { + const { sup, deps } = await build({ + turn: async (_prompt, emit) => { + await emit({ + sessionUpdate: "tool_call", + toolCallId: "t1", + title: "Read docs/10-operations-runbook.md", + kind: "read", + status: "completed", + }); + await emit({ + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "The runbook says to please run /login as the service user.", + }, + }); + return "end_turn"; + }, + }); + const markExited = vi.fn(async () => {}); + (deps as { markExited?: typeof markExited }).markExited = markExited; + const stopSpy = vi.spyOn(HarnessDriver.prototype, "stop"); + await sup.start("agt_1"); + await sup.prompt("agt_1", "what does the runbook say"); + // Restore before asserting: the spy is on the prototype, so a failure + // here would otherwise leak it into the next test. + const stops = stopSpy.mock.calls.length; + stopSpy.mockRestore(); + expect(markExited).not.toHaveBeenCalled(); + expect(stops).toBe(0); + expect(sup.isRunning("agt_1")).toBe(true); + await sup.stop("agt_1"); + }); +}); diff --git a/apps/server/test/harness-usage-recorder.test.ts b/apps/server/test/harness-usage-recorder.test.ts new file mode 100644 index 000000000..089f92c17 --- /dev/null +++ b/apps/server/test/harness-usage-recorder.test.ts @@ -0,0 +1,91 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import type { DriverEvent } from "../src/agents/harness/driver.js"; +import { UsageRecorder } from "../src/agents/harness/usage-recorder.js"; +import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; + +let pool: Pool; +const A = "agt_usage_a"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); + await pool.query( + `INSERT INTO agents (id, name, cwd, status) VALUES ($1, 'U', '/tmp', 'running')`, + [A] + ); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +beforeEach(async () => { + await pool.query("DELETE FROM agent_token_usage WHERE agent_id = $1", [A]); +}); + +const settled = (input: number, output: number): DriverEvent => ({ + type: "turn", + agentId: A, + state: "settled", + stopReason: "end_turn", + usage: { + totalTokens: input + output, + inputTokens: input, + outputTokens: output, + thoughtTokens: 0, + cachedReadTokens: 5, + cachedWriteTokens: 1, + }, +}); + +describe("UsageRecorder", () => { + it("upserts cumulative totals per agent, session, and model", async () => { + const rec = new UsageRecorder(pool); + const ctx = { sessionId: "sess_1", model: "openai/gpt-5.2" }; + await rec.handle(settled(100, 10), ctx); + await rec.handle(settled(250, 40), ctx); + const rows = await pool.query( + `SELECT input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, message_count + FROM agent_token_usage + WHERE agent_id = $1 AND session_id = $2 AND model = $3`, + [A, "sess_1", "openai/gpt-5.2"] + ); + expect(rows.rows).toEqual([ + { + input_tokens: 250, + output_tokens: 40, + cache_read_tokens: 5, + cache_creation_tokens: 1, + message_count: 2, + }, + ]); + }); + + it("ignores turns without usage and non-turn events", async () => { + const rec = new UsageRecorder(pool); + const ctx = { sessionId: "s", model: "m" }; + await rec.handle( + { type: "turn", agentId: A, state: "started", text: "x" }, + ctx + ); + await rec.handle( + { type: "turn", agentId: A, state: "settled", stopReason: "end_turn" }, + ctx + ); + await rec.handle( + { + type: "update", + agentId: A, + update: { sessionUpdate: "usage_update", used: 10, size: 100 }, + }, + ctx + ); + const rows = await pool.query( + `SELECT 1 FROM agent_token_usage WHERE agent_id = $1`, + [A] + ); + expect(rows.rowCount).toBe(0); + }); +}); diff --git a/apps/server/test/harness-usage-report.test.ts b/apps/server/test/harness-usage-report.test.ts new file mode 100644 index 000000000..4b5014113 --- /dev/null +++ b/apps/server/test/harness-usage-report.test.ts @@ -0,0 +1,176 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import { + loadAgentUsage, + loadUsageReport, + monthStartUtc, +} from "../src/agents/harness/usage.js"; +import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; + +let pool: Pool; +const NOW = new Date(Date.UTC(2026, 8, 7, 12, 0, 0)); + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); +}); +afterAll(async () => { + await teardownTestDb(); +}); +beforeEach(async () => { + await pool.query("DELETE FROM agent_stream_events"); + await pool.query("DELETE FROM agent_token_usage"); + await pool.query("DELETE FROM agents"); +}); + +async function agent(id: string, name: string, model: string | null) { + await pool.query( + `INSERT INTO agents (id, name, cwd, status, type, model) VALUES ($1, $2, '/tmp', 'running', 'dispatch', $3)`, + [id, name, model] + ); +} + +async function tokens( + agentId: string, + session: string, + input: number, + output: number, + at: Date +) { + await pool.query( + `INSERT INTO agent_token_usage (agent_id, session_id, model, input_tokens, cache_creation_tokens, cache_read_tokens, output_tokens, message_count, session_start, session_end) + VALUES ($1, $2, 'm', $3, 0, 0, $4, 1, $5, $5)`, + [agentId, session, input, output, at] + ); +} + +async function turn(agentId: string, seq: number, usage: unknown, at: Date) { + await pool.query( + `INSERT INTO agent_stream_events (agent_id, seq, kind, payload, created_at, updated_at) + VALUES ($1, $2, 'turn', $3::jsonb, $4, $4)`, + [ + agentId, + seq, + JSON.stringify({ + state: "settled", + prompt: { source: "system", text: "x" }, + usage, + }), + at, + ] + ); +} + +describe("monthStartUtc", () => { + it("is midnight UTC on the first", () => { + expect(monthStartUtc(NOW).toISOString()).toBe("2026-09-01T00:00:00.000Z"); + }); +}); + +describe("loadUsageReport", () => { + it("groups agents by engine with tokens from agent_token_usage and the newest cost from turn rows", async () => { + await agent("agt_a", "A", "claude/default"); + await agent("agt_b", "B", "codex/gpt-5.6-sol"); + await agent("agt_c", "C", "gemini/default"); + await tokens("agt_a", "s1", 1000, 200, NOW); + await tokens("agt_a", "s0", 1000, 200, new Date(Date.UTC(2026, 7, 30))); // last month, ignored + await tokens("agt_b", "s2", 50, 5, NOW); + await turn( + "agt_a", + 1, + { used: 100, size: 1000, cost: { amount: 0.1, currency: "USD" } }, + NOW + ); + await turn( + "agt_a", + 2, + { used: 200, size: 1000, cost: { amount: 0.35, currency: "USD" } }, + NOW + ); + await turn("agt_b", 1, { used: 200, size: 1000 }, NOW); + const report = await loadUsageReport(pool, { claude: 20 }, NOW); + expect(report.monthStart).toBe("2026-09-01T00:00:00.000Z"); + const by = Object.fromEntries(report.engines.map((e) => [e.id, e])); + expect(by.claude).toMatchObject({ + tokens: 1200, + costUsd: 0.35, + budgetUsd: 20, + agents: [{ agentId: "agt_a", name: "A", tokens: 1200, costUsd: 0.35 }], + }); + expect(by.codex).toMatchObject({ + tokens: 55, + costUsd: null, + budgetUsd: null, + agents: [{ agentId: "agt_b", name: "B", tokens: 55, costUsd: null }], + }); + expect(by.gemini).toMatchObject({ + tokens: 0, + costUsd: null, + agents: [{ agentId: "agt_c", tokens: 0, costUsd: null }], + }); + expect(by.opencode).toMatchObject({ tokens: 0, costUsd: null, agents: [] }); + }); + + it("counts an agent with no model stored under the default engine", async () => { + // The default create path stores the default harness model now, but rows + // written before that still carry no model, and the child runs the + // default engine either way. + await agent("agt_d", "D", null); + await tokens("agt_d", "s1", 40, 2, NOW); + const report = await loadUsageReport(pool, {}, NOW); + const by = Object.fromEntries(report.engines.map((e) => [e.id, e])); + expect(by.claude).toMatchObject({ + tokens: 42, + agents: [{ agentId: "agt_d", name: "D", tokens: 42 }], + }); + }); + + it("reports no cost for a cost the engine gave in another currency", async () => { + await agent("agt_e", "E", "opencode/default"); + await turn( + "agt_e", + 1, + { used: 1, size: 2, cost: { amount: 4.5, currency: "EUR" } }, + NOW + ); + const report = await loadUsageReport(pool, { opencode: 10 }, NOW); + const opencode = report.engines.find((e) => e.id === "opencode"); + expect(opencode?.costUsd).toBeNull(); + expect(opencode?.agents).toEqual([ + { agentId: "agt_e", name: "E", tokens: 0, costUsd: null }, + ]); + expect(await loadAgentUsage(pool, "agt_e", NOW)).toMatchObject({ + costUsd: null, + }); + }); + + it("ignores non-harness agents and agents with an unknown engine", async () => { + await pool.query( + `INSERT INTO agents (id, name, cwd, status, type, model) VALUES ('agt_t', 'T', '/tmp', 'running', 'claude', 'opus')` + ); + await agent("agt_u", "U", "unknown/x"); + const report = await loadUsageReport(pool, {}, NOW); + expect(report.engines.flatMap((e) => e.agents)).toEqual([]); + }); +}); + +describe("loadAgentUsage", () => { + it("returns one agent's month, or null for an unknown agent", async () => { + await agent("agt_a", "A", "opencode/default"); + await tokens("agt_a", "s1", 10, 1, NOW); + await turn( + "agt_a", + 1, + { used: 1, size: 2, cost: { amount: 1.25, currency: "USD" } }, + NOW + ); + expect(await loadAgentUsage(pool, "agt_a", NOW)).toEqual({ + agentId: "agt_a", + name: "A", + tokens: 11, + costUsd: 1.25, + }); + expect(await loadAgentUsage(pool, "agt_nope", NOW)).toBeNull(); + }); +}); diff --git a/apps/server/test/helpers/fake-acp-agent.ts b/apps/server/test/helpers/fake-acp-agent.ts new file mode 100644 index 000000000..eb40cd5e7 --- /dev/null +++ b/apps/server/test/helpers/fake-acp-agent.ts @@ -0,0 +1,174 @@ +import { EventEmitter } from "node:events"; +import { PassThrough, Readable, Writable } from "node:stream"; +import * as acp from "@agentclientprotocol/sdk"; + +export type FakeTurn = ( + prompt: string, + emit: (update: acp.SessionUpdate) => Promise, + ask: ( + request: Pick + ) => Promise, + /** Fires when the client cancels the turn; a long turn should stop then. */ + signal: AbortSignal +) => Promise< + acp.StopReason | { stopReason: acp.StopReason; usage?: acp.Usage } +>; + +/** + * An in-process ACP agent wired to a ChildProcess-like object. The driver's + * injected `spawn` returns `child`; the fake agent speaks on the other ends + * of the same pipes, so no real process is involved. + */ +export function createFakeAcpAgent( + opts: { + turn?: FakeTurn; + resumeFails?: boolean; + /** Commands advertised right after a session opens. */ + commands?: acp.AvailableCommand[]; + /** Config options returned with the session. */ + configOptions?: acp.SessionConfigOption[]; + /** + * The child ignores stdin EOF and every signal, the way a hung engine or + * a grandchild of one does. Signals are still recorded in `signals`. + */ + ignoreSignals?: boolean; + } = {} +) { + const toAgent = new PassThrough(); // driver stdin -> agent input + const fromAgent = new PassThrough(); // agent output -> driver stdout + const stderr = new PassThrough(); + const emitter = new EventEmitter(); + const signals: (NodeJS.Signals | number)[] = []; + const child = Object.assign(emitter, { + stdin: toAgent, + stdout: fromAgent, + stderr, + killed: false, + kill(signal?: NodeJS.Signals | number) { + signals.push(signal ?? "SIGTERM"); + if (opts.ignoreSignals) return true; + if (child.killed) return true; + child.killed = true; + queueMicrotask(() => emitter.emit("exit", null, signal ?? "SIGTERM")); + return true; + }, + }); + // A real child exits when its stdin closes; mirror that so the driver's + // teardown ladder settles without a signal. + toAgent.on("end", () => { + if (opts.ignoreSignals) return; + if (!child.killed) { + child.killed = true; + queueMicrotask(() => emitter.emit("exit", 0, null)); + } + }); + + const seen = { + initialize: [] as acp.InitializeRequest[], + newSession: [] as acp.NewSessionRequest[], + resumeSession: [] as acp.ResumeSessionRequest[], + setMode: [] as acp.SetSessionModeRequest[], + setConfig: [] as acp.SetSessionConfigOptionRequest[], + prompts: [] as string[], + cancels: 0, + closes: 0, + }; + let sessionCounter = 0; + // Assigned below; the agent's prompt handler needs it to push updates. + let connection: acp.AgentSideConnection; + // The turn in flight, so a cancel can reach it. + let inFlight: AbortController | null = null; + + const announce = (sessionId: string) => { + if (!opts.commands) return; + setTimeout(() => { + void connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: opts.commands ?? [], + }, + }); + }, 0); + }; + + const agent: acp.Agent = { + async initialize(params) { + seen.initialize.push(params); + return { + protocolVersion: acp.PROTOCOL_VERSION, + agentInfo: { name: "fake-acp-agent", version: "0.0.0" }, + agentCapabilities: { + mcpCapabilities: { http: true }, + sessionCapabilities: { close: {}, resume: {} }, + }, + authMethods: [], + }; + }, + async authenticate() { + return {}; + }, + async newSession(params) { + seen.newSession.push(params); + const sessionId = `sess_${++sessionCounter}`; + announce(sessionId); + return { sessionId, configOptions: opts.configOptions ?? [] }; + }, + async resumeSession(params) { + seen.resumeSession.push(params); + if (opts.resumeFails) throw new Error("unknown session"); + announce(params.sessionId); + return { configOptions: opts.configOptions ?? [] }; + }, + async setSessionMode(params) { + seen.setMode.push(params); + return {}; + }, + async setSessionConfigOption(params) { + seen.setConfig.push(params); + const options = (opts.configOptions ?? []).map((o) => + o.id === params.configId ? { ...o, currentValue: params.value } : o + ); + return { configOptions: options }; + }, + async prompt(params) { + const text = params.prompt + .map((b) => (b.type === "text" ? b.text : "")) + .join(""); + seen.prompts.push(text); + const emit = (update: acp.SessionUpdate) => + connection.sessionUpdate({ sessionId: params.sessionId, update }); + const ask = (request: Pick) => + connection.requestPermission({ + sessionId: params.sessionId, + toolCall: { toolCallId: "perm_1", title: "permission" }, + options: request.options, + }); + const controller = new AbortController(); + inFlight = controller; + try { + const result = opts.turn + ? await opts.turn(text, emit, ask, controller.signal) + : "end_turn"; + return typeof result === "string" ? { stopReason: result } : result; + } finally { + if (inFlight === controller) inFlight = null; + } + }, + async cancel() { + seen.cancels += 1; + inFlight?.abort(); + }, + async closeSession() { + seen.closes += 1; + return {}; + }, + }; + + const stream = acp.ndJsonStream( + Writable.toWeb(fromAgent), + Readable.toWeb(toAgent) + ); + connection = new acp.AgentSideConnection(() => agent, stream); + return { child, seen, signals, stderr }; +} diff --git a/apps/server/test/install-dispatch.test.ts b/apps/server/test/install-dispatch.test.ts index 9c8baa807..cae26b133 100644 --- a/apps/server/test/install-dispatch.test.ts +++ b/apps/server/test/install-dispatch.test.ts @@ -14,6 +14,18 @@ describe("install-dispatch systemd unit", () => { expect(script).toContain("KillMode=process"); }); + it("gives the macOS service longer than launchd's default to shut down", async () => { + // The shutdown budget is archives 10 s, in-flight deliveries 5 s, + // harness reconcile 2 s and the harness stop ladder 7 s: past launchd's + // 20 s default, at which point it SIGKILLs the server mid-teardown and + // the engine children survive as orphans. + const script = await readFile( + path.join(REPO_ROOT, "bin", "install-dispatch.sh"), + "utf8" + ); + expect(script).toContain("ExitTimeOut30"); + }); + it("does not add a shell-environment marker to either service", async () => { const script = await readFile( path.join(REPO_ROOT, "bin", "install-dispatch.sh"), diff --git a/apps/server/test/mcp-handlers.test.ts b/apps/server/test/mcp-handlers.test.ts index 641456e25..ba399bb1d 100644 --- a/apps/server/test/mcp-handlers.test.ts +++ b/apps/server/test/mcp-handlers.test.ts @@ -48,15 +48,22 @@ vi.mock("../src/reviews/injection-prompts.js", () => ({ })); vi.mock("../src/agent-type-settings.js", () => ({ - CLI_AGENT_TYPES: ["claude", "codex", "cursor", "opencode"], + CLI_AGENT_TYPES: ["claude", "codex", "cursor", "opencode", "dispatch"], getEnabledAgentTypes: vi.fn(async () => [ "claude", "codex", "cursor", "opencode", ]), + getOfferedAgentTypes: vi.fn(async () => [ + "claude", + "codex", + "cursor", + "opencode", + "dispatch", + ]), isCliAgentType: vi.fn((t: string) => - ["claude", "codex", "cursor", "opencode"].includes(t) + ["claude", "codex", "cursor", "opencode", "dispatch"].includes(t) ), })); @@ -138,7 +145,7 @@ import { loadPersonaBySlug, } from "../src/personas/loader.js"; import { GENERIC_REVIEW_PERSONA_SLUG } from "../src/personas/built-in.js"; -import { getEnabledAgentTypes } from "../src/agent-type-settings.js"; +import { getOfferedAgentTypes } from "../src/agent-type-settings.js"; import { isMediaFile, isTextFile, @@ -972,7 +979,7 @@ describe("createMcpHandlers", () => { }); it("throws when agent type is disabled", async () => { - vi.mocked(getEnabledAgentTypes).mockResolvedValue([]); + vi.mocked(getOfferedAgentTypes).mockResolvedValue([]); await expect( handlers.launchPersona("agt_test1", { persona: "security", @@ -982,7 +989,7 @@ describe("createMcpHandlers", () => { }); it("includes full-access arg for claude agents with fullAccess", async () => { - vi.mocked(getEnabledAgentTypes).mockResolvedValue([ + vi.mocked(getOfferedAgentTypes).mockResolvedValue([ "claude", "codex", "opencode", @@ -1016,7 +1023,7 @@ describe("createMcpHandlers", () => { }); it("includes full-access arg for codex agents with fullAccess", async () => { - vi.mocked(getEnabledAgentTypes).mockResolvedValue([ + vi.mocked(getOfferedAgentTypes).mockResolvedValue([ "claude", "codex", "opencode", @@ -1052,7 +1059,7 @@ describe("createMcpHandlers", () => { }); it("does not include full-access arg for opencode agents", async () => { - vi.mocked(getEnabledAgentTypes).mockResolvedValue([ + vi.mocked(getOfferedAgentTypes).mockResolvedValue([ "claude", "codex", "opencode", @@ -1193,6 +1200,78 @@ describe("createMcpHandlers", () => { }); }); + describe("harness children inherit the parent's engine", () => { + // The engine is the first segment of a harness model id, so a child that + // runs as its parent's kind with no model of its own would otherwise run + // on Claude Code whatever the parent runs on, and bill that account. + const harnessParent = { + id: "agt_test1", + name: "test-agent", + cwd: "/repo", + status: "running", + type: "dispatch", + model: "codex/gpt-5.6-sol", + fullAccess: true, + pins: [], + latestEvent: null, + worktreePath: null, + worktreeBranch: null, + baseBranch: null, + reviewAgentType: null, + mediaDir: null, + }; + + beforeEach(() => { + // vi.clearAllMocks() clears calls, not implementations, so an earlier + // test's narrower offered-types list is still in place here. + vi.mocked(getOfferedAgentTypes).mockResolvedValue([ + "claude", + "codex", + "cursor", + "opencode", + "dispatch", + ] as never); + deps.agentManager.getAgent.mockResolvedValue(harnessParent); + }); + + it("a persona review runs on the parent's engine", async () => { + await handlers.launchPersona("agt_test1", { + persona: "security", + context: "review this PR", + }); + expect(deps.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + type: "dispatch", + model: "codex/gpt-5.6-sol", + }) + ); + }); + + it("a launched child runs on the parent's engine", async () => { + await handlers.launchAgent("agt_test1", { + name: "worker", + prompt: "work", + }); + expect(deps.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + type: "dispatch", + model: "codex/gpt-5.6-sol", + }) + ); + }); + + it("an explicit model still wins", async () => { + await handlers.launchAgent("agt_test1", { + name: "worker", + prompt: "work", + model: "gemini/default", + }); + expect(deps.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ model: "gemini/default" }) + ); + }); + }); + describe("launchAgent", () => { it("refuses to launch a child under a parent that is being archived", async () => { deps.agentManager.getAgent.mockResolvedValue({ @@ -1382,7 +1461,7 @@ describe("createMcpHandlers", () => { }); it("throws when agent type is disabled in settings", async () => { - vi.mocked(getEnabledAgentTypes).mockResolvedValueOnce(["codex"] as any); + vi.mocked(getOfferedAgentTypes).mockResolvedValueOnce(["codex"] as any); await expect( handlers.launchAgent("agt_test1", { name: "child", diff --git a/apps/server/test/mcp-review-handlers.test.ts b/apps/server/test/mcp-review-handlers.test.ts index aade00e5e..2c93c7e96 100644 --- a/apps/server/test/mcp-review-handlers.test.ts +++ b/apps/server/test/mcp-review-handlers.test.ts @@ -64,6 +64,9 @@ vi.mock("../src/agent-type-settings.js", () => ({ getEnabledAgentTypes: vi .fn() .mockResolvedValue(["claude", "codex", "opencode"]), + getOfferedAgentTypes: vi + .fn() + .mockResolvedValue(["claude", "codex", "opencode"]), isCliAgentType: vi.fn((t: string) => ["claude", "codex", "opencode"].includes(t) ), diff --git a/apps/server/test/mcp-url.test.ts b/apps/server/test/mcp-url.test.ts index f36d5eb66..62648dc0f 100644 --- a/apps/server/test/mcp-url.test.ts +++ b/apps/server/test/mcp-url.test.ts @@ -15,6 +15,9 @@ function makeConfig(overrides: Partial = {}): AppConfig { claudeBin: "", opencodeBin: "", cursorBin: "", + claudeHarnessBin: "", + codexHarnessBin: "", + geminiBin: "", agentRuntime: "tmux", sessionPrefix: "dispatch", tls: null, diff --git a/apps/server/test/migrations-harness.test.ts b/apps/server/test/migrations-harness.test.ts new file mode 100644 index 000000000..f34a95d08 --- /dev/null +++ b/apps/server/test/migrations-harness.test.ts @@ -0,0 +1,251 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import { runTestMigrations, setupTestDb, teardownTestDb } from "./db/setup.js"; + +// The names earlier prereleases of this branch wrote into pgmigrations for +// files this branch no longer ships (migrate.ts deletes them at boot). +const PRERELEASE_MIGRATION_NAMES = [ + "0048_agent-stream-events", + "0049_agent-stream-events-turn", + "0050_agent-chat-messages-delivery-text", + "0051_agent-type-dispatch", + "0052_agent-stream-events-turn", + "0053_agent-chat-messages-delivery-text", + "0054_agent-type-dispatch", +]; + +// The subset of those names written by the lineage whose stream-events file +// was called `0051_agent-stream-events`, the name this branch also ships. That +// collision is why the shipped file was skipped on such a database and its +// `kind` CHECK never learned 'plan'. +const LINEAGE_B_MIGRATION_NAMES = [ + "0052_agent-stream-events-turn", + "0053_agent-chat-messages-delivery-text", + "0054_agent-type-dispatch", +]; + +/** + * Forget `name` and every harness migration shipped after it. The runner + * compares the stored names against the shipped ones position by position + * and throws on a gap, so a case that simulates one missing record has to + * drop the later ones too. Every harness file is guarded, so re-running the + * tail is a no-op on a database that already has its objects. + */ +async function forgetFrom(name: string): Promise { + const from = HARNESS_MIGRATION_NAMES.indexOf(name); + if (from === -1) + throw new Error(`${name} is not a shipped harness migration`); + await pool.query(`DELETE FROM pgmigrations WHERE name = ANY($1::text[])`, [ + HARNESS_MIGRATION_NAMES.slice(from), + ]); +} + +/** + * Put the stream table back into the shape that lineage left it in: the + * narrower CHECK, and no plan rows. A database on that lineage has none + * because every plan write was rejected, which is the defect; the earlier + * tests in this file wrote one, so it goes before the constraint narrows. + */ +async function narrowKindCheckToExcludePlan(): Promise { + await pool.query(`DELETE FROM agent_stream_events WHERE kind = 'plan'`); + await pool.query( + `ALTER TABLE agent_stream_events DROP CONSTRAINT IF EXISTS agent_stream_events_kind_check` + ); + await pool.query(`ALTER TABLE agent_stream_events + ADD CONSTRAINT agent_stream_events_kind_check + CHECK (kind IN ('assistant', 'thought', 'tool_call', 'status', 'turn'))`); +} + +// The harness migrations this branch ships, in shipped order. Every one +// added after 0051 belongs here: the first case below deletes the whole set +// and re-runs it, and the runner throws on a gap in the middle, so a file +// left off this list breaks that case rather than going unnoticed. +const HARNESS_MIGRATION_NAMES = [ + "0051_agent-stream-events", + "0052_agent-chat-messages-delivery-text", + "0053_agent-stream-events-kind", + "0054_agent-stream-events-turn-prompt", + "0055_dispatch-harness-carry-over", + "0056_agents-chat-read-at", + "0057_agent-chat-reactions", +]; + +let pool: Pool; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +describe("harness migrations", () => { + it("re-run their SQL against an existing schema without error (every statement is guarded)", async () => { + // node-pg-migrate skips names already in pgmigrations, so a plain second + // run executes nothing. Forgetting the three rows makes it execute all + // three files again on a database that already has their objects: the + // case an install upgraded from the earlier harness migrations is in. + // They go together because the runner also checks that the stored names + // are a prefix of the shipped ones, so a gap in the middle throws. + await pool.query(`DELETE FROM pgmigrations WHERE name = ANY($1::text[])`, [ + HARNESS_MIGRATION_NAMES, + ]); + await expect(runTestMigrations()).resolves.not.toThrow(); + const rows = await pool.query<{ name: string }>( + `SELECT name FROM pgmigrations WHERE name = ANY($1::text[]) ORDER BY name`, + [HARNESS_MIGRATION_NAMES] + ); + expect(rows.rows.map((r) => r.name)).toEqual(HARNESS_MIGRATION_NAMES); + }); + + it("accept a plan row and a delivery_text column", async () => { + await pool.query( + `INSERT INTO agents (id, name, cwd, status) VALUES ('agt_mig', 'M', '/tmp', 'running')` + ); + await expect( + pool.query( + `INSERT INTO agent_stream_events (agent_id, seq, kind, key, payload) + VALUES ('agt_mig', 1, 'plan', 'plan:1', '{"entries":[]}'::jsonb)` + ) + ).resolves.toBeDefined(); + const column = await pool.query( + `SELECT 1 FROM information_schema.columns + WHERE table_name = 'agent_chat_messages' AND column_name = 'delivery_text'` + ); + expect(column.rowCount).toBe(1); + const index = await pool.query( + `SELECT 1 FROM pg_indexes WHERE indexname = 'agent_stream_events_agent_created'` + ); + expect(index.rowCount).toBe(1); + }); + + it("boot on a database that ran an earlier prerelease of this branch", async () => { + // The prerelease records are dated a year back on purpose. The runner + // reads pgmigrations ordered by (run_on, id) and compares that list + // against the shipped files position by position, so the dates decide + // where the dead names land: ahead of the files this branch does ship, + // where the prerelease wrote them, which is what makes the comparison + // throw before the first migration executes. + await pool.query( + `INSERT INTO pgmigrations (name, run_on) + SELECT name, NOW() - INTERVAL '1 year' FROM unnest($1::text[]) AS t(name)`, + [PRERELEASE_MIGRATION_NAMES] + ); + // 'dsh' is the agent type value one of those prereleases renamed in a + // migration this branch does not ship. Every place that migration + // rewrote is seeded here, because a value left behind in any of them + // reads as the harness disappearing from that row. + await pool.query( + `INSERT INTO agents (id, name, cwd, status, type, review_agent_type) + VALUES ('agt_prerelease', 'P', '/tmp', 'running', 'dsh', 'dsh')` + ); + await pool.query( + `INSERT INTO jobs (id, directory, name, agent_type) + VALUES ('job_prerelease', '/tmp', 'nightly', 'dsh')` + ); + await pool.query( + `INSERT INTO templates (id, directory, name, prompt, agent_type) + VALUES ('tpl_prerelease', '/tmp', 'review', 'go', 'dsh')` + ); + await pool.query( + `INSERT INTO agent_events (agent_id, event_type, message, agent_type) + VALUES ('agt_prerelease', 'working', 'x', 'dsh')` + ); + await pool.query( + `INSERT INTO settings (key, value) VALUES ('enabled_agent_types', $1) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [JSON.stringify(["claude", "dsh", "terminal"])] + ); + + await expect(runTestMigrations()).resolves.not.toThrow(); + + const dead = await pool.query<{ name: string }>( + `SELECT name FROM pgmigrations WHERE name = ANY($1::text[])`, + [PRERELEASE_MIGRATION_NAMES] + ); + expect(dead.rows).toEqual([]); + const live = await pool.query<{ name: string; count: string }>( + `SELECT name, COUNT(*)::text AS count FROM pgmigrations + WHERE name = ANY($1::text[]) GROUP BY name ORDER BY name`, + [HARNESS_MIGRATION_NAMES] + ); + expect(live.rows).toEqual( + HARNESS_MIGRATION_NAMES.map((name) => ({ name, count: "1" })) + ); + const agent = await pool.query<{ + type: string; + review_agent_type: string; + }>( + `SELECT type, review_agent_type FROM agents WHERE id = 'agt_prerelease'` + ); + expect(agent.rows[0]).toEqual({ + type: "dispatch", + review_agent_type: "dispatch", + }); + const carried = await pool.query<{ table_name: string; value: string }>( + `SELECT 'jobs' AS table_name, agent_type AS value FROM jobs WHERE id = 'job_prerelease' + UNION ALL + SELECT 'templates', agent_type FROM templates WHERE id = 'tpl_prerelease' + UNION ALL + SELECT 'agent_events', agent_type FROM agent_events WHERE agent_id = 'agt_prerelease' + UNION ALL + SELECT 'settings', value FROM settings WHERE key = 'enabled_agent_types' + ORDER BY table_name` + ); + expect(carried.rows).toEqual([ + { table_name: "agent_events", value: "dispatch" }, + { table_name: "jobs", value: "dispatch" }, + { table_name: "settings", value: '["claude","dispatch","terminal"]' }, + { table_name: "templates", value: "dispatch" }, + ]); + }); + it("repair a kind CHECK that predates 'plan' with no prerelease records left to key on", async () => { + // A database that already booted the release which forgets the prerelease + // bookkeeping is in this shape: the dead records are gone, so nothing is + // left to detect the lineage by, and the table still carries the narrow + // CHECK its own file created. The repair therefore has to be a migration + // of its own rather than a rule about which records to delete. + await narrowKindCheckToExcludePlan(); + await forgetFrom("0053_agent-stream-events-kind"); + + await expect(runTestMigrations()).resolves.not.toThrow(); + + await expect( + pool.query( + `INSERT INTO agent_stream_events (agent_id, seq, kind, key, payload) + VALUES ('agt_mig', 41, 'plan', 'plan:41', '{"entries":[]}'::jsonb)` + ) + ).resolves.toBeDefined(); + }); + + it("repair that CHECK on a database still carrying the prerelease records", async () => { + // The untouched lineage-B shape. `0051_agent-stream-events` is a live + // record because that lineage used the name this branch ships, while the + // delivery-text and kind files this branch ships were never run there. + await forgetFrom("0052_agent-chat-messages-delivery-text"); + + await pool.query( + `INSERT INTO pgmigrations (name, run_on) + SELECT name, NOW() FROM unnest($1::text[]) AS t(name)`, + [LINEAGE_B_MIGRATION_NAMES] + ); + await narrowKindCheckToExcludePlan(); + + await expect(runTestMigrations()).resolves.not.toThrow(); + + const dead = await pool.query<{ name: string }>( + `SELECT name FROM pgmigrations WHERE name = ANY($1::text[])`, + [LINEAGE_B_MIGRATION_NAMES] + ); + expect(dead.rows).toEqual([]); + await expect( + pool.query( + `INSERT INTO agent_stream_events (agent_id, seq, kind, key, payload) + VALUES ('agt_mig', 42, 'plan', 'plan:42', '{"entries":[]}'::jsonb)` + ) + ).resolves.toBeDefined(); + }); +}); diff --git a/apps/server/test/release-routes.test.ts b/apps/server/test/release-routes.test.ts index 02e84cfba..30f77ede4 100644 --- a/apps/server/test/release-routes.test.ts +++ b/apps/server/test/release-routes.test.ts @@ -57,7 +57,11 @@ const rootPackageVersion = ( ) ) as { version: string } ).version; -const packagedCurrentTag = `v${rootPackageVersion}`; +// A custom patch release (e.g. 0.38.7-custom.2) is not plain semver, and the +// route's fallback yields no current tag for it. +const packagedCurrentTag = /^\d+\.\d+\.\d+$/.test(rootPackageVersion) + ? `v${rootPackageVersion}` + : null; beforeAll(async () => { await mkdir(path.join(os.homedir(), ".dispatch", "server"), { @@ -370,7 +374,9 @@ describe("release metadata route handling", () => { expect(response.json()).toMatchObject({ currentTag: packagedCurrentTag, latestTag: "v0.18.36", - updateAvailable: compareSemverForTest("v0.18.36", packagedCurrentTag) > 0, + updateAvailable: + packagedCurrentTag !== null && + compareSemverForTest("v0.18.36", packagedCurrentTag) > 0, }); }); @@ -491,6 +497,120 @@ describe("release metadata route handling", () => { }); }); + it("picks a CLI agent type while the Dispatch Harness is on", async () => { + // Confirms the picker still finds a normal CLI type when the harness + // flag is on; the exclusion itself is exercised by the next test, since + // `dispatch` is appended last here and "claude" is found before it. + await ctx.pool.query( + `INSERT INTO settings (key, value) VALUES ('enabled_agent_types', $1) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [JSON.stringify(["claude"])] + ); + await ctx.pool.query( + `INSERT INTO settings (key, value) + VALUES ('dispatch_harness_enabled', 'true') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value` + ); + mockReleaseCommands({ + releaseViews: { + "v0.19.0": validReleaseView({ + body: releaseBody( + JSON.stringify({ + mode: "required", + title: "Bun runtime migration", + summary: "Switch runtime from Node to Bun.", + requiredChecks: ["service_restarted"], + appliesFrom: "v0.18.0", + }) + ), + }), + }, + }); + + try { + const response = await ctx.app.inject({ + method: "POST", + url: "/api/v1/release/assisted/launch", + headers: { cookie: sessionCookie, "content-type": "application/json" }, + payload: { tag: "v0.19.0" }, + }); + expect(response.statusCode).toBe(201); + expect(response.json().agent.type).toBe("claude"); + } finally { + // The launch persists assisted state and the enabled types; leaving + // either behind would 409 every launch test after this one. + await ctx.app.inject({ + method: "DELETE", + url: "/api/v1/release/assisted/state", + headers: { cookie: sessionCookie }, + }); + await ctx.pool.query( + `DELETE FROM settings + WHERE key IN ('enabled_agent_types', 'dispatch_harness_enabled')` + ); + } + }); + + it("never picks a Dispatch Harness agent to drive the update", async () => { + // `dispatch` is always appended last by getOfferedAgentTypes, and the + // sanitizer's empty-list fallback restores every CLI type, so the only + // enabled-list shape where the exclusion actually decides the outcome is + // a non-CLI type on its own. "terminal" is a real AgentType outside + // CLI_AGENT_TYPES, so it survives the sanitizer without triggering that + // fallback and leaves `dispatch` as the sole (excluded) CLI candidate. + await ctx.pool.query( + `INSERT INTO settings (key, value) VALUES ('enabled_agent_types', $1) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [JSON.stringify(["terminal"])] + ); + await ctx.pool.query( + `INSERT INTO settings (key, value) + VALUES ('dispatch_harness_enabled', 'true') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value` + ); + mockReleaseCommands({ + releaseViews: { + "v0.19.0": validReleaseView({ + body: releaseBody( + JSON.stringify({ + mode: "required", + title: "Bun runtime migration", + summary: "Switch runtime from Node to Bun.", + requiredChecks: ["service_restarted"], + appliesFrom: "v0.18.0", + }) + ), + }), + }, + }); + + try { + const response = await ctx.app.inject({ + method: "POST", + url: "/api/v1/release/assisted/launch", + headers: { cookie: sessionCookie, "content-type": "application/json" }, + payload: { tag: "v0.19.0" }, + }); + expect(response.statusCode).toBe(422); + expect(response.json().error).toBe( + "No CLI agent types are enabled. Enable Codex, Claude, or OpenCode first." + ); + } finally { + // A 422 never persists assisted state, but the enabled-types row + // still needs cleanup to keep later launch tests from seeing + // "terminal" as the only enabled type. + await ctx.app.inject({ + method: "DELETE", + url: "/api/v1/release/assisted/state", + headers: { cookie: sessionCookie }, + }); + await ctx.pool.query( + `DELETE FROM settings + WHERE key IN ('enabled_agent_types', 'dispatch_harness_enabled')` + ); + } + }); + it("rejects /release/assisted/launch when the target release metadata is malformed", async () => { mockReleaseCommands({ releaseViews: { diff --git a/apps/server/test/system-routes.test.ts b/apps/server/test/system-routes.test.ts index 9f2c70e2c..dd04a8aae 100644 --- a/apps/server/test/system-routes.test.ts +++ b/apps/server/test/system-routes.test.ts @@ -636,6 +636,22 @@ describe("POST /api/v1/app/settings/agent-types", () => { expect(res.json().enabledAgentTypes).toEqual(["claude", "codex"]); }); + // A body naming the harness used to be accepted and then silently + // sanitized away, so a stale caller looked like it had worked. Say no, + // and say where the switch actually is. + it("rejects a body that names the harness, pointing at its own endpoint", async () => { + const res = await ctx.app.inject({ + method: "POST", + url: "/api/v1/app/settings/agent-types", + headers: { cookie: sessionCookie }, + payload: { enabledAgentTypes: ["claude", "dispatch"] }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe( + "dispatch is not set here. Turn the Dispatch Harness on or off at POST /api/v1/app/settings/dispatch-harness." + ); + }); + it("rejects duplicate-inflated array with unknown entries", async () => { const res = await ctx.app.inject({ method: "POST", @@ -657,6 +673,81 @@ describe("POST /api/v1/app/settings/agent-types", () => { }); }); +describe("/api/v1/app/settings/dispatch-harness", () => { + // Order-independent: other tests in this file persist settings rows, and + // this one owns its key. + beforeEach(async () => { + await ctx.pool.query( + "DELETE FROM settings WHERE key = 'dispatch_harness_enabled'" + ); + }); + + it("reads false before anyone has set it", async () => { + const res = await ctx.app.inject({ + method: "GET", + url: "/api/v1/app/settings/dispatch-harness", + headers: { cookie: sessionCookie }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ enabled: false }); + }); + + it("round trips a POST through the GET", async () => { + const post = await ctx.app.inject({ + method: "POST", + url: "/api/v1/app/settings/dispatch-harness", + headers: { cookie: sessionCookie }, + payload: { enabled: true }, + }); + expect(post.statusCode).toBe(200); + expect(post.json()).toEqual({ enabled: true }); + + const get = await ctx.app.inject({ + method: "GET", + url: "/api/v1/app/settings/dispatch-harness", + headers: { cookie: sessionCookie }, + }); + expect(get.json()).toEqual({ enabled: true }); + }); + + it("turns the flag back off", async () => { + await ctx.app.inject({ + method: "POST", + url: "/api/v1/app/settings/dispatch-harness", + headers: { cookie: sessionCookie }, + payload: { enabled: true }, + }); + const off = await ctx.app.inject({ + method: "POST", + url: "/api/v1/app/settings/dispatch-harness", + headers: { cookie: sessionCookie }, + payload: { enabled: false }, + }); + expect(off.json()).toEqual({ enabled: false }); + }); + + it("rejects a non-boolean enabled", async () => { + const res = await ctx.app.inject({ + method: "POST", + url: "/api/v1/app/settings/dispatch-harness", + headers: { cookie: sessionCookie }, + payload: { enabled: "true" }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("enabled must be a boolean."); + }); + + it("rejects a body with no enabled at all", async () => { + const res = await ctx.app.inject({ + method: "POST", + url: "/api/v1/app/settings/dispatch-harness", + headers: { cookie: sessionCookie }, + payload: {}, + }); + expect(res.statusCode).toBe(400); + }); +}); + describe("GET /api/v1/app/settings/ides", () => { it("returns enabled IDEs", async () => { const res = await ctx.app.inject({ @@ -838,3 +929,55 @@ describe("POST /api/v1/energy-report", () => { expect(res.statusCode).toBe(204); }); }); + +describe("usage budgets settings", () => { + const url = "/api/v1/app/settings/usage-budgets"; + it("starts empty, stores known providers, and rejects the rest", async () => { + const empty = await ctx.app.inject({ + method: "GET", + url, + headers: { cookie: sessionCookie }, + }); + expect(empty.statusCode).toBe(200); + expect(empty.json()).toEqual({ budgets: {} }); + + const saved = await ctx.app.inject({ + method: "POST", + url, + headers: { cookie: sessionCookie, "content-type": "application/json" }, + payload: { budgets: { claude: 50, opencode: 12.345 } }, + }); + expect(saved.statusCode).toBe(200); + expect(saved.json()).toEqual({ budgets: { claude: 50, opencode: 12.35 } }); + const read = await ctx.app.inject({ + method: "GET", + url, + headers: { cookie: sessionCookie }, + }); + expect(read.json()).toEqual({ budgets: { claude: 50, opencode: 12.35 } }); + + for (const payload of [ + { budgets: [] }, + { budgets: { nope: 5 } }, + { budgets: { claude: -1 } }, + { budgets: { claude: "50" } }, + ]) { + const bad = await ctx.app.inject({ + method: "POST", + url, + headers: { cookie: sessionCookie, "content-type": "application/json" }, + payload, + }); + expect(bad.statusCode).toBe(400); + } + + // An empty object clears every row. + const cleared = await ctx.app.inject({ + method: "POST", + url, + headers: { cookie: sessionCookie, "content-type": "application/json" }, + payload: { budgets: {} }, + }); + expect(cleared.json()).toEqual({ budgets: {} }); + }); +}); diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index d7112eb40..00ea5c644 100644 --- a/apps/server/test/tmux-command-builder.test.ts +++ b/apps/server/test/tmux-command-builder.test.ts @@ -21,6 +21,9 @@ const baseConfig: AppConfig = { claudeBin: "/opt/claude", opencodeBin: "/opt/opencode", cursorBin: "/opt/cursor", + claudeHarnessBin: "/opt/claude-agent-acp", + codexHarnessBin: "/opt/codex-acp", + geminiBin: "/opt/gemini", agentRuntime: "inert", sessionPrefix: "dispatch", tls: null, @@ -1111,6 +1114,18 @@ describe("buildLaunchGuidance — trimmed variant", () => { expect(text).toContain("auto-corrected"); }); + it("requires accepted tasks to continue past plan-only turns", () => { + for (const agentType of ["claude", "codex"] as const) { + for (const trimmedGuidance of [false, true]) { + const text = guidance({ agentType, trimmedGuidance }); + expect(text).toContain( + "do not end a turn after only announcing a plan or status" + ); + expect(text).toContain("Continue into substantive work"); + } + } + }); + it("folds the two pin rules into one", () => { const full = guidance({ agentType: "claude" }); const text = guidance({ agentType: "claude", trimmedGuidance: true }); @@ -1253,3 +1268,24 @@ describe("buildLaunchGuidance — chat surface rule", () => { expect(without).not.toContain("dispatch_chat_post"); }); }); + +describe("dispatch harness agents", () => { + it("launch into a login shell like terminal agents; the ACP supervisor owns the engine", () => { + const cmd = buildAgentCommand( + baseConfig, + "dispatch", + "standard", + [], + "/tmp/media", + SESSION, + false + ); + expect(cmd).toContain('"${SHELL:-/bin/bash}" -il'); + expect(cmd).not.toContain("--mcp-config"); + expect(cmd).not.toContain("--append-system-prompt"); + expect(cmd).not.toContain("split-window"); + expect(cmd).not.toContain("tail -n 300"); + // The dispatch pane is a plain login shell, the same line terminal agents get. + expect(cmd.trim().endsWith('"${SHELL:-/bin/bash}" -il')).toBe(true); + }); +}); diff --git a/apps/web/public/harness-icon.svg b/apps/web/public/harness-icon.svg new file mode 100644 index 000000000..eb4304897 --- /dev/null +++ b/apps/web/public/harness-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/public/icons/amber/harness-icon.svg b/apps/web/public/icons/amber/harness-icon.svg new file mode 100644 index 000000000..39f4e7214 --- /dev/null +++ b/apps/web/public/icons/amber/harness-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/public/icons/blue/harness-icon.svg b/apps/web/public/icons/blue/harness-icon.svg new file mode 100644 index 000000000..ae68df016 --- /dev/null +++ b/apps/web/public/icons/blue/harness-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/public/icons/cyan/harness-icon.svg b/apps/web/public/icons/cyan/harness-icon.svg new file mode 100644 index 000000000..b8a4a7ba2 --- /dev/null +++ b/apps/web/public/icons/cyan/harness-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/public/icons/orange/harness-icon.svg b/apps/web/public/icons/orange/harness-icon.svg new file mode 100644 index 000000000..c0e271f96 --- /dev/null +++ b/apps/web/public/icons/orange/harness-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/public/icons/pink/harness-icon.svg b/apps/web/public/icons/pink/harness-icon.svg new file mode 100644 index 000000000..9d7d4a1f6 --- /dev/null +++ b/apps/web/public/icons/pink/harness-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/public/icons/purple/harness-icon.svg b/apps/web/public/icons/purple/harness-icon.svg new file mode 100644 index 000000000..25359b316 --- /dev/null +++ b/apps/web/public/icons/purple/harness-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/public/icons/red/harness-icon.svg b/apps/web/public/icons/red/harness-icon.svg new file mode 100644 index 000000000..163888b82 --- /dev/null +++ b/apps/web/public/icons/red/harness-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/public/icons/teal/harness-icon.svg b/apps/web/public/icons/teal/harness-icon.svg new file mode 100644 index 000000000..f5cd167b0 --- /dev/null +++ b/apps/web/public/icons/teal/harness-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 34d2c3e2b..b9525d111 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Outlet, useMatches, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import "@xterm/xterm/css/xterm.css"; @@ -17,9 +17,11 @@ import { useIconColor } from "@/hooks/use-icon-color"; import { useInstanceName } from "@/hooks/use-instance-name"; import { useTheme } from "@/hooks/use-theme"; import { useTemporaryState } from "@/hooks/use-temporary-state"; +import { useDispatchHarnessEnabled } from "@/hooks/use-dispatch-harness-enabled"; import { - AGENT_TYPES, type AgentType, + DEFAULT_ENABLED_AGENT_TYPES, + offeredAgentTypes, sanitizeEnabledAgentTypes, } from "@/lib/agent-types"; import { type IdeType, sanitizeEnabledIdes } from "@/lib/ide-types"; @@ -67,10 +69,18 @@ export function DashboardLayout(): JSX.Element { } = useLayout(); const { apiState, dbState } = useHealth(true); + // The pre-fetch guess. `dispatch` is not in it: it never comes back from + // the agent-types endpoint, and offering it before the flag resolves would + // show a type that then vanishes. const [enabledAgentTypes, setEnabledAgentTypes] = useState([ - ...AGENT_TYPES, + ...DEFAULT_ENABLED_AGENT_TYPES, ]); const [enabledIdes, setEnabledIdes] = useState([]); + const { enabled: dispatchHarnessEnabled } = useDispatchHarnessEnabled(); + const offered = useMemo( + () => offeredAgentTypes(enabledAgentTypes, dispatchHarnessEnabled), + [enabledAgentTypes, dispatchHarnessEnabled] + ); const { data: agents = [] } = useQuery({ queryKey: ["agents"], queryFn: async () => { @@ -173,6 +183,7 @@ export function DashboardLayout(): JSX.Element { const context: DashboardContextValue = { agents, enabledAgentTypes, + offeredAgentTypes: offered, setEnabledAgentTypes, enabledIdes, setEnabledIdes, diff --git a/apps/web/src/components/app/agent-card-details.tsx b/apps/web/src/components/app/agent-card-details.tsx index bff5f508d..365b74af2 100644 --- a/apps/web/src/components/app/agent-card-details.tsx +++ b/apps/web/src/components/app/agent-card-details.tsx @@ -8,6 +8,8 @@ import { GitBranch, } from "lucide-react"; +import { harnessEngineOf } from "@dispatch/shared"; + import { FrontTruncatedValue } from "@/components/app/agent-meta"; import { DiffStatBadge } from "@/components/app/diff-stat-badge"; import { IdeLaunchButton } from "@/components/app/ide-launch-button"; @@ -94,6 +96,16 @@ export function AgentCardDetails({ copyWorktreePath, }: AgentCardDetailsProps): JSX.Element { const sidebarBaseBranch = agent.baseBranch ?? "main"; + // A harness agent's engine lives in the first segment of its model id, and + // nothing outside the pane showed it: the type label is the product name + // and one icon serves all four engines, so two harness agents on different + // engines read identically in the sidebar. + const engine = + agent.type === "dispatch" ? harnessEngineOf(agent.model) : null; + const engineModel = + agent.model && agent.model.includes("/") + ? agent.model.slice(agent.model.indexOf("/") + 1) + : null; return (
@@ -152,6 +164,18 @@ export function AgentCardDetails({ ) : null} )} + {engine ? ( +
+ +
+ ) : null}
{agent.cwd ? ( diff --git a/apps/web/src/components/app/agent-card.test.tsx b/apps/web/src/components/app/agent-card.test.tsx index 9b0770705..ffa496c82 100644 --- a/apps/web/src/components/app/agent-card.test.tsx +++ b/apps/web/src/components/app/agent-card.test.tsx @@ -496,6 +496,31 @@ describe("AgentCardDetails wiring", () => { expect(screen.queryByText("Sandboxed")).toBeNull(); }); + it("names the engine a harness agent runs, and nothing for other kinds", () => { + // Outside the pane nothing said which engine: the type label is the + // product name and the icon is the same for all four, so a Claude and a + // Codex harness agent were indistinguishable in the sidebar. + const { rerender } = renderCard({ + agent: makeAgent({ type: "dispatch", model: "codex/gpt-5.6-sol" }), + expandedAgentId: AGENT_ID, + }); + expect(screen.getByTestId("agent-card-engine").textContent).toContain( + "Codex" + ); + expect(screen.getByTestId("agent-card-engine").textContent).toContain( + "gpt-5.6-sol" + ); + + // An engine that fixes its model at launch still names the engine. + rerender({ agent: makeAgent({ type: "dispatch", model: null }) }); + expect(screen.getByTestId("agent-card-engine").textContent).toContain( + "Claude Code" + ); + + rerender({ agent: makeAgent({ type: "claude", model: "opus" }) }); + expect(screen.queryByTestId("agent-card-engine")).toBeNull(); + }); + it("keeps the worktree-path copy confirmation across a collapse and reopen", async () => { const { rerender } = renderCard({ agent: worktreeAgent, diff --git a/apps/web/src/components/app/agent-model-select.test.tsx b/apps/web/src/components/app/agent-model-select.test.tsx index 3d78ae37d..bd2be8dbc 100644 --- a/apps/web/src/components/app/agent-model-select.test.tsx +++ b/apps/web/src/components/app/agent-model-select.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AgentModelSelect } from "./agent-model-select"; @@ -35,6 +35,31 @@ describe("AgentModelSelect", () => { } ); + it("splits the harness engine from its model", () => { + const grouped = [ + { + id: "claude/default", + label: "Claude Code default", + group: "Claude Code", + }, + { id: "codex/default", label: "Codex default", group: "Codex" }, + ]; + const onChange = vi.fn(); + render( + + ); + expect(screen.getByText("Provider")).toBeTruthy(); + expect( + screen.getByTestId("create-agent-model-engine").textContent + ).toContain("Claude Code"); + expect(screen.getByTestId("create-agent-model").textContent).toContain( + "Claude Code default" + ); + fireEvent.click(screen.getByTestId("create-agent-model-engine")); + fireEvent.click(screen.getByRole("option", { name: "Codex" })); + expect(onChange).toHaveBeenLastCalledWith("codex/default"); + }); + it("keeps the trigger labelled while the catalog loads", () => { render( @@ -44,3 +69,30 @@ describe("AgentModelSelect", () => { expect(trigger).toHaveProperty("disabled", true); }); }); + +describe("groupModelOptions", () => { + it("buckets by group in first-seen order, ungrouped under none", async () => { + const { groupModelOptions } = await import("./agent-model-select"); + expect( + groupModelOptions([ + { id: "a", label: "A", group: "Codex" }, + { id: "b", label: "B" }, + { id: "c", label: "C", group: "Gemini CLI" }, + { id: "d", label: "D", group: "Codex" }, + ]) + ).toEqual([ + { + group: "Codex", + options: [ + { id: "a", label: "A", group: "Codex" }, + { id: "d", label: "D", group: "Codex" }, + ], + }, + { group: null, options: [{ id: "b", label: "B" }] }, + { + group: "Gemini CLI", + options: [{ id: "c", label: "C", group: "Gemini CLI" }], + }, + ]); + }); +}); diff --git a/apps/web/src/components/app/agent-model-select.tsx b/apps/web/src/components/app/agent-model-select.tsx index af973e65e..cff26184b 100644 --- a/apps/web/src/components/app/agent-model-select.tsx +++ b/apps/web/src/components/app/agent-model-select.tsx @@ -1,12 +1,32 @@ +import { DEFAULT_HARNESS_MODEL } from "@dispatch/shared"; + import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; -type AgentModelOption = { id: string; label: string }; +type AgentModelOption = { id: string; label: string; group?: string }; + +export function groupModelOptions( + options: readonly AgentModelOption[] +): { group: string | null; options: AgentModelOption[] }[] { + const out: { group: string | null; options: AgentModelOption[] }[] = []; + for (const option of options) { + const group = option.group ?? null; + let bucket = out.find((b) => b.group === group); + if (!bucket) { + bucket = { group, options: [] }; + out.push(bucket); + } + bucket.options.push(option); + } + return out; +} type AgentModelSelectProps = { value: string | null; @@ -36,10 +56,95 @@ export function AgentModelSelect({ ? value : DEFAULT_VALUE; + const grouped = options.some((option) => option.group); + if (grouped) { + const groups = groupModelOptions(options).filter( + (bucket): bucket is { group: string; options: AgentModelOption[] } => + bucket.group !== null + ); + const resolved = + options.find((option) => option.id === value) ?? + options.find((option) => option.id === DEFAULT_HARNESS_MODEL) ?? + options[0]; + const selectedGroup = + groups.find((bucket) => bucket.group === resolved?.group) ?? groups[0]; + const modelValue = resolved?.id ?? ""; + return ( +
+
+ + +
+
+ + +
+
+ ); + } + return (
diff --git a/apps/web/src/components/app/agent-pane.test.tsx b/apps/web/src/components/app/agent-pane.test.tsx index e423bc235..ce1e852aa 100644 --- a/apps/web/src/components/app/agent-pane.test.tsx +++ b/apps/web/src/components/app/agent-pane.test.tsx @@ -52,6 +52,53 @@ vi.mock("@/hooks/use-chat", () => ({ return () => ({ mutate }); })(), })); +vi.mock( + "@/components/app/harness/use-harness-config", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/components/app/harness/use-harness-config") + >()), + useHarnessConfig: () => ({ + running: false, + options: [], + model: undefined, + effort: undefined, + loading: false, + }), + useSetHarnessConfig: () => ({ mutateAsync: vi.fn(), isPending: false }), + }) +); +vi.mock("@/components/app/harness/use-harness-commands", () => ({ + harnessCommandsQueryKey: (agentId: string | null) => [ + "harness-commands", + agentId, + ], + useHarnessCommands: () => [], +})); +vi.mock("@/components/app/harness/use-harness-queue", () => ({ + harnessQueueQueryKey: (agentId: string | null) => ["harness-queue", agentId], + useQueuedPrompts: () => ({ queued: [], loading: false, error: null }), + useHarnessQueue: () => ({ + sendNow: vi.fn(), + remove: vi.fn(), + busyId: null, + }), + useHarnessInterrupt: () => ({ interrupt: vi.fn(), interrupting: false }), +})); +vi.mock("@/components/app/harness/use-harness-usage", () => ({ + HARNESS_USAGE_QUERY_KEY: ["harness-usage"], + useHarnessUsage: () => ({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + refetch: vi.fn(), + }), +})); +vi.mock("@/components/app/harness/use-harness-auth", () => ({ + HARNESS_AUTH_QUERY_KEY: ["harness-auth"], + useHarnessAuth: () => ({ data: undefined }), +})); vi.mock("@/hooks/use-injection-hold-state", () => ({ useInjectionHoldState: () => null, })); @@ -80,6 +127,10 @@ function agentNamed(id: string): Agent { }; } +function dispatchAgentNamed(id: string): Agent { + return { ...agentNamed(id), type: "dispatch", model: "codex/default" }; +} + function wrapper({ children }: { children: ReactNode }) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -396,3 +447,29 @@ describe("AgentPane", () => { ); }); }); + +describe("AgentPane for a dispatch agent", () => { + it("hosts the chat pane like every other type, with no harness pane left", () => { + renderPane({ agent: dispatchAgentNamed("agt_a"), view: "chat" }); + expect(isHidden(screen.getByTestId("agent-pane-chat"))).toBe(false); + expect(screen.getByTestId("chat-pane")).toBeTruthy(); + expect(screen.queryByTestId("harness-pane")).toBeNull(); + expect(isHidden(screen.getByTestId("agent-pane-console"))).toBe(true); + expect(screen.getByTestId("chat-harness-chrome")).toBeTruthy(); + }); + + it("keeps the Console segment and the chat filter, and shows unread under Console", () => { + renderPane({ + agent: dispatchAgentNamed("agt_a"), + view: "console", + chatUnreadCount: 3, + }); + expect(screen.getByTestId("agent-view-console")).toBeTruthy(); + expect(screen.getByTestId("agent-view-chat")).toBeTruthy(); + expect(screen.queryByTestId("agent-view-harness")).toBeNull(); + expect(screen.getByTestId("chat-filters-trigger")).toBeTruthy(); + expect(screen.getByTestId("agent-view-chat-unread").textContent).toBe("3"); + expect(screen.getByTestId("chat-pane")).toBeTruthy(); + expect(isHidden(screen.getByTestId("agent-pane-console"))).toBe(false); + }); +}); diff --git a/apps/web/src/components/app/agent-pane.tsx b/apps/web/src/components/app/agent-pane.tsx index b2092a4d1..beb7517c0 100644 --- a/apps/web/src/components/app/agent-pane.tsx +++ b/apps/web/src/components/app/agent-pane.tsx @@ -273,7 +273,7 @@ export function AgentPane({ onOpenReview, isMobile, }: AgentPaneProps): JSX.Element { - const chatShown = chatEnabled && view === "chat"; + const feedShown = chatEnabled && view === "chat"; const reduceMotion = useReducedMotion(); // The chat-surface flag resolves after the first paint, so the pane can go // from "bare terminal" to "Chat over Console" a tick in. That is hydration @@ -318,13 +318,13 @@ export function AgentPane({ {/* * Keyed per agent: the pane's dismissed question, send error and @@ -336,12 +336,13 @@ export function AgentPane({ agentId={agentId} agent={agent} terminalMode={terminalMode} - active={active && chatShown} + active={active && feedShown} showChildAgents={showChildAgents} childAgentIds={childAgentIds} onShowChildAgentsChange={onShowChildAgentsChange} openLightbox={openLightbox} onOpenReview={onOpenReview} + onOpenConsole={() => onViewChange("console")} isMobile={isMobile} /> @@ -349,18 +350,18 @@ export function AgentPane({ {/* * `min-w-0 overflow-hidden` is load-bearing, not tidiness: the slot diff --git a/apps/web/src/components/app/agent-type-icon.test.tsx b/apps/web/src/components/app/agent-type-icon.test.tsx new file mode 100644 index 000000000..2e770b4ea --- /dev/null +++ b/apps/web/src/components/app/agent-type-icon.test.tsx @@ -0,0 +1,17 @@ +// @vitest-environment jsdom +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { AgentTypeIcon } from "./agent-type-icon"; + +vi.mock("@/hooks/use-icon-color", () => ({ + useIconColor: () => ({ iconColor: "teal" }), +})); + +describe("AgentTypeIcon for the Dispatch Harness", () => { + it("wears the harness icon in the chosen color", () => { + render(); + const img = screen.getByLabelText("Dispatch agent").querySelector("img"); + expect(img?.getAttribute("src")).toBe("/icons/teal/harness-icon.svg"); + }); +}); diff --git a/apps/web/src/components/app/agent-type-icon.tsx b/apps/web/src/components/app/agent-type-icon.tsx index 140ec1085..03f0975f1 100644 --- a/apps/web/src/components/app/agent-type-icon.tsx +++ b/apps/web/src/components/app/agent-type-icon.tsx @@ -1,6 +1,9 @@ import { Bot, Terminal as TerminalIcon } from "lucide-react"; + +import { useIconColor } from "@/hooks/use-icon-color"; import { siClaude, siCursor } from "simple-icons"; +import { AGENT_TYPE_LABELS } from "@/lib/agent-types"; import { cn } from "@/lib/utils"; type AgentEventType = "working" | "blocked" | "waiting_user" | "done" | "idle"; @@ -25,7 +28,14 @@ const CODEX_LOGO_PATH = function normalizeAgentType( type?: string | null -): "codex" | "claude" | "opencode" | "cursor" | "terminal" | "unknown" { +): + | "codex" + | "claude" + | "opencode" + | "cursor" + | "dispatch" + | "terminal" + | "unknown" { if (type === "claude") { return "claude"; } @@ -38,6 +48,9 @@ function normalizeAgentType( if (type === "terminal") { return "terminal"; } + if (type === "dispatch") { + return "dispatch"; + } if (type === "codex") { return "codex"; } @@ -54,17 +67,7 @@ export function AgentTypeIcon({ }: AgentTypeIconProps): JSX.Element { const normalizedType = normalizeAgentType(type); const label = - normalizedType === "claude" - ? "Claude" - : normalizedType === "opencode" - ? "OpenCode" - : normalizedType === "cursor" - ? "Cursor" - : normalizedType === "terminal" - ? "Terminal" - : normalizedType === "codex" - ? "Codex" - : "Agent"; + normalizedType === "unknown" ? "Agent" : AGENT_TYPE_LABELS[normalizedType]; const statusClass = eventType ? eventColorClass[eventType] : ""; const baseClass = statusClass ? "inline-flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors duration-300" @@ -87,6 +90,15 @@ export function AgentTypeIcon({ ); } + if (normalizedType === "dispatch") { + return ( + + ); + } + if (normalizedType === "terminal") { return ( ); } + +function DispatchHarnessMark({ + className, + label, +}: { + className: string; + label: string; +}): JSX.Element { + const { iconColor } = useIconColor(); + return ( + + + + ); +} diff --git a/apps/web/src/components/app/agent-type-settings.test.tsx b/apps/web/src/components/app/agent-type-settings.test.tsx new file mode 100644 index 000000000..bc0d4e8a0 --- /dev/null +++ b/apps/web/src/components/app/agent-type-settings.test.tsx @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AgentTypeSettings } from "./agent-type-settings"; + +const apiMock = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/api", () => ({ api: apiMock })); + +beforeEach(() => { + apiMock.mockReset(); + apiMock.mockResolvedValue({ + enabledAgentTypes: ["claude", "codex", "terminal"], + }); +}); + +afterEach(() => { + cleanup(); +}); + +describe("AgentTypeSettings", () => { + it("offers every CLI type and the terminal", async () => { + render( + + ); + + await waitFor(() => + expect(screen.getByTestId("agent-type-toggle-claude")).not.toBeNull() + ); + for (const type of ["codex", "cursor", "opencode", "terminal"]) { + expect(screen.getByTestId(`agent-type-toggle-${type}`)).not.toBeNull(); + } + }); + + // The Dispatch Harness has its own card. A checkbox here would be a second + // switch, and its POST would be refused by the server. + it("does not offer the harness", async () => { + render( + + ); + + await waitFor(() => + expect(screen.getByTestId("agent-type-toggle-claude")).not.toBeNull() + ); + expect(screen.queryByTestId("agent-type-toggle-dispatch")).toBeNull(); + expect(screen.queryByText(/Dispatch's own view over/)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/app/agent-type-settings.tsx b/apps/web/src/components/app/agent-type-settings.tsx index c06bfba45..1e9b171c4 100644 --- a/apps/web/src/components/app/agent-type-settings.tsx +++ b/apps/web/src/components/app/agent-type-settings.tsx @@ -9,15 +9,31 @@ import { CLI_AGENT_TYPES, } from "@/lib/agent-types"; +/** + * Every type this card can toggle. The Dispatch Harness is not one: it has + * its own setting (`DispatchHarnessSettings`), and the server answers 400 to + * an agent-types POST that names it, so a checkbox here would be a switch + * that cannot be saved. + */ +type ToggleableAgentType = Exclude; + +function isToggleableAgentType(type: AgentType): type is ToggleableAgentType { + return type !== "dispatch"; +} + +const TOGGLEABLE_CLI_AGENT_TYPES: ToggleableAgentType[] = ( + CLI_AGENT_TYPES as readonly AgentType[] +).filter(isToggleableAgentType); + type AgentTypeSettingsResponse = { enabledAgentTypes: AgentType[]; }; -const AGENT_TYPE_DESCRIPTIONS: Record = { +const AGENT_TYPE_DESCRIPTIONS: Record = { claude: "Claude Code CLI by Anthropic.", codex: "Codex CLI by OpenAI.", cursor: "Cursor Agent CLI by Anysphere.", - opencode: "OpenCode CLI — open-source terminal agent.", + opencode: "OpenCode CLI, an open-source terminal agent.", terminal: "Raw shell session with no AI agent.", }; @@ -32,7 +48,7 @@ function AgentTypeRow({ disabled, onToggle, }: { - agentType: AgentType; + agentType: ToggleableAgentType; checked: boolean; disabled: boolean; onToggle: () => void; @@ -70,12 +86,17 @@ export function AgentTypeSettings({ enabledAgentTypes, onChange, }: AgentTypeSettingsProps): JSX.Element { - const [agentTypes, setAgentTypes] = useState(enabledAgentTypes); + // Filtered on the way in as well as out: a prerelease install can still + // have `dispatch` in the persisted row, and letting it into this state + // would put it in the next POST body, which the server refuses. + const [agentTypes, setAgentTypes] = useState(() => + enabledAgentTypes.filter(isToggleableAgentType) + ); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); useEffect(() => { - setAgentTypes(enabledAgentTypes); + setAgentTypes(enabledAgentTypes.filter(isToggleableAgentType)); }, [enabledAgentTypes]); useEffect(() => { @@ -84,7 +105,7 @@ export function AgentTypeSettings({ void api("/api/v1/app/settings/agent-types") .then((data) => { if (cancelled) return; - setAgentTypes(data.enabledAgentTypes); + setAgentTypes(data.enabledAgentTypes.filter(isToggleableAgentType)); onChange(data.enabledAgentTypes); setError(""); }) @@ -106,7 +127,7 @@ export function AgentTypeSettings({ }, [onChange]); const toggleAgentType = useCallback( - async (agentType: AgentType) => { + async (agentType: ToggleableAgentType) => { setError(""); const next = agentTypes.includes(agentType) @@ -125,7 +146,7 @@ export function AgentTypeSettings({ body: JSON.stringify({ enabledAgentTypes: next }), } ); - setAgentTypes(data.enabledAgentTypes); + setAgentTypes(data.enabledAgentTypes.filter(isToggleableAgentType)); onChange(data.enabledAgentTypes); } catch (err) { // Revert on failure @@ -153,12 +174,13 @@ export function AgentTypeSettings({

Choose which agent runtimes can be created from the app. Disabled - types are removed from the create-agent dialog. + types are removed from the create-agent dialog. The Dispatch Harness + has its own switch below.

- {CLI_AGENT_TYPES.map((agentType) => { + {TOGGLEABLE_CLI_AGENT_TYPES.map((agentType) => { const checked = agentTypes.includes(agentType); const disabled = checked && agentTypes.length === 1; return ( diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index f14c96ab5..a710bbcc8 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -239,9 +239,11 @@ export function AgentsView({ // CLI to chat with, so it keeps the plain Terminal tab and Console-only // pane however the flag is set. An empty workspace likewise has no Chat // target and should not render the Agent-pane view switch. + // A harness agent's engine streams into Chat and has no CLI in its pane, so + // Chat is on for it whether or not the global surface flag is. const chatEnabled = - chatSurfaceEnabled && focusedAgent !== null && + (chatSurfaceEnabled || focusedAgent.type === "dispatch") && agentSupportsChat(focusedAgent.type); const activeTab: CenterTab = changesMatch ? "changes" diff --git a/apps/web/src/components/app/automations-form-fields.tsx b/apps/web/src/components/app/automations-form-fields.tsx index 4d0bdbfaa..bf668f53c 100644 --- a/apps/web/src/components/app/automations-form-fields.tsx +++ b/apps/web/src/components/app/automations-form-fields.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { AlwaysFullAccessNote } from "@/components/app/full-access-note"; import { GitBranch, Paperclip } from "lucide-react"; import { AgentTypeSelect } from "@/components/app/agent-type-select"; @@ -69,10 +70,14 @@ export function TemplateWorktreeOption({ export function TemplateFullAccessOption({ checked, onCheckedChange, + alwaysOn = false, }: { checked: boolean; onCheckedChange: (checked: boolean) => void; + /** The chosen agent type has no sandboxed mode; the note explains. */ + alwaysOn?: boolean; }): JSX.Element { + if (alwaysOn) return ; return (