From cc10de4272d7a89e1d6d77a3c730d2951804bdac Mon Sep 17 00:00:00 2001 From: Brendan Date: Mon, 14 Sep 2026 17:02:48 +0000 Subject: [PATCH] Deliver agent prompts at launch --- package-lock.json | 18 ++++++++++++ package.json | 3 +- src/exe-herdr-backend.ts | 2 +- src/exe.ts | 32 +++++++++++++++------ src/execution.ts | 1 + src/tenant.ts | 15 ++++------ test/agent-launch.e2e.test.ts | 50 +++++++++++++++++++++++++++++++++ test/execution-backend.test.ts | 4 ++- test/fixtures/fake-herdr.sh | 51 ++++++++++++++++++++++++++++++++++ test/workspace.test.ts | 16 ++++++++--- vitest.agent-e2e.config.ts | 8 ++++++ vitest.e2e.config.ts | 2 +- 12 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 test/agent-launch.e2e.test.ts create mode 100755 test/fixtures/fake-herdr.sh create mode 100644 vitest.agent-e2e.config.ts diff --git a/package-lock.json b/package-lock.json index 3886b30..f8abb67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@cloudflare/workers-types": "^5.20260910.1", "@tailwindcss/cli": "^4.1.14", "@types/mustache": "^4.2.6", + "@types/node": "^26.5.1", "tailwindcss": "^4.1.14", "typescript": "^5.9.3", "vitest": "^3.2.4", @@ -3611,6 +3612,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -4966,6 +4977,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", diff --git a/package.json b/package.json index 34eabc2..a55fcfb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build:css": "tailwindcss -i ./src/tailwind.css -o ./public/styles.css --minify", "check": "tsc --noEmit && vitest run && npm run test:e2e && wrangler deploy --dry-run", "test": "vitest run", - "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:e2e": "vitest run --config vitest.e2e.config.ts && vitest run --config vitest.agent-e2e.config.ts", "deploy": "wrangler deploy" }, "dependencies": { @@ -24,6 +24,7 @@ "@cloudflare/workers-types": "^5.20260910.1", "@tailwindcss/cli": "^4.1.14", "@types/mustache": "^4.2.6", + "@types/node": "^26.5.1", "tailwindcss": "^4.1.14", "typescript": "^5.9.3", "vitest": "^3.2.4", diff --git a/src/exe-herdr-backend.ts b/src/exe-herdr-backend.ts index de8a01c..015bd26 100644 --- a/src/exe-herdr-backend.ts +++ b/src/exe-herdr-backend.ts @@ -8,7 +8,7 @@ export class ExeHerdrBackend implements ExecutionBackend { constructor(private readonly connection: ExeConnection) {} async launch(request: LaunchRequest) { - const command = await exec(this.connection, launchAgentCommand(request.agentName, this.connection, request.workspaceName, request.runPath, request.lease)); + const command = await exec(this.connection, launchAgentCommand(request.agentName, this.connection, request.workspaceName, request.runPath, request.lease, request.prompt)); return { handle: { backend: this.kind, agentName: request.agentName }, command }; } diff --git a/src/exe.ts b/src/exe.ts index ca44986..a1049ea 100644 --- a/src/exe.ts +++ b/src/exe.ts @@ -41,9 +41,12 @@ export function shellAtom(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } -export function launchAgentCommand(agentName: string, connection: ExeConnection, workspaceName: string, runPath: string, lease: string): string { +export function launchAgentCommand(agentName: string, connection: ExeConnection, workspaceName: string, runPath: string, lease: string, prompt: string): string { const name = agentName; const herdr = herdrBinary(connection); + const promptDirectory = `/tmp/factorize-prompts/${agentName}`; + const promptPath = `${promptDirectory}/prompt.md`; + const launchInstruction = `Read and follow the complete task instructions in ${promptPath}`; return [ herdrPrefix(connection), `cd ${shellAtom(connection.cwd)}`, @@ -51,22 +54,32 @@ export function launchAgentCommand(agentName: string, connection: ExeConnection, `if [ ! -e ${shellAtom(runPath)} ]; then mkdir ${shellAtom(runPath)}; fi`, `test -d ${shellAtom(runPath)}`, `if [ -e ${shellAtom(`${runPath}/.factorize-lease`)} ]; then test "$(cat ${shellAtom(`${runPath}/.factorize-lease`)})" = ${shellAtom(lease)}; else printf '%s' ${shellAtom(lease)} > ${shellAtom(`${runPath}/.factorize-lease`)}; fi`, + `mkdir -p ${shellAtom(promptDirectory)}`, + `chmod 700 ${shellAtom(promptDirectory)}`, + `printf '%s' ${shellAtom(base64(prompt))} | base64 -d > ${shellAtom(promptPath)}`, + `chmod 600 ${shellAtom(promptPath)}`, `workspaces=$(${herdr} workspace list)`, `workspace_id=$(printf '%s' "$workspaces" | jq -r --arg label ${shellAtom(workspaceName)} '.result.workspaces[]? | select(.label == $label) | .workspace_id' | head -n1)`, `if [ -z "$workspace_id" ]; then created=$(${herdr} workspace create --cwd ${shellAtom(runPath)} --label ${shellAtom(workspaceName)} --no-focus) && workspace_id=$(printf '%s' "$created" | jq -er '.result.workspace.workspace_id') && tab_id=$(printf '%s' "$created" | jq -er '.result.tab.tab_id') && pane=$(printf '%s' "$created" | jq -er '.result.root_pane.pane_id') && ${herdr} tab rename "$tab_id" ${shellAtom(name)} >/dev/null; else tabs=$(${herdr} tab list --workspace "$workspace_id") && tab_id=$(printf '%s' "$tabs" | jq -r --arg label ${shellAtom(name)} '.result.tabs[]? | select(.label == $label) | .tab_id' | head -n1); if [ -z "$tab_id" ]; then created=$(${herdr} tab create --workspace "$workspace_id" --cwd ${shellAtom(runPath)} --label ${shellAtom(name)} --no-focus) && tab_id=$(printf '%s' "$created" | jq -er '.result.tab.tab_id') && pane=$(printf '%s' "$created" | jq -er '.result.root_pane.pane_id'); else panes=$(${herdr} pane list --workspace "$workspace_id") && pane=$(printf '%s' "$panes" | jq -r --arg tab "$tab_id" '.result.panes[]? | select(.tab_id == $tab) | .pane_id' | head -n1); test -n "$pane"; extras=$(printf '%s' "$panes" | jq -r --arg tab "$tab_id" --arg keep "$pane" '.result.panes[]? | select(.tab_id == $tab and .pane_id != $keep) | .pane_id'); for extra in $extras; do ${herdr} pane close "$extra" >/dev/null; done; fi; fi`, `existing=$(${herdr} agent get ${shellAtom(name)} 2>/dev/null || true)`, - `if [ -n "$existing" ]; then printf '%s\\n' "$existing"; else ${herdr} agent start ${shellAtom(name)} --kind ${shellAtom(connection.agentKind)} --pane "$pane"${agentCommand(connection)} && ${herdr} agent get ${shellAtom(name)}; fi`, + `if [ -n "$existing" ]; then printf '%s\\n' "$existing"; else ${herdr} agent start ${shellAtom(name)} --kind ${shellAtom(connection.agentKind)} --pane "$pane"${agentCommand(connection, launchInstruction, runPath)} && ${herdr} agent get ${shellAtom(name)}; fi`, ].join(" && "); } export function promptAgentCommand(connection: ExeConnection, agentName: string, prompt: string): string { const encodedPrompt = base64(prompt), herdr = herdrBinary(connection); - return `${herdrPrefix(connection)} && prompt=$(printf '%s' ${shellAtom(encodedPrompt)} | base64 -d) && ${herdr} agent prompt ${shellAtom(agentName)} "$prompt"`; + const name = shellAtom(agentName); + return [ + herdrPrefix(connection), + `${herdr} agent wait ${name} --until idle --until done --timeout 15000 >/dev/null`, + `prompt=$(printf '%s' ${shellAtom(encodedPrompt)} | base64 -d)`, + `${herdr} agent prompt ${name} "$prompt" --wait --until working --until blocked --timeout 7000`, + ].join(" && "); } /** Compatibility helper used by recovery paths; prompt delivery is never skipped. */ export function startAgentCommand(agentName: string, connection: ExeConnection, prompt: string, workspaceName: string, runPath: string, lease: string): string { - return `${launchAgentCommand(agentName, connection, workspaceName, runPath, lease)} && ${promptAgentCommand(connection, agentName, prompt)}`; + return launchAgentCommand(agentName, connection, workspaceName, runPath, lease, prompt); } export function agentListCommand(connection: ExeConnection): string { return `${herdrPrefix(connection)} && ${herdrBinary(connection)} agent list`; } @@ -74,14 +87,14 @@ export function paneGetCommand(connection: ExeConnection, paneId: string): strin export function paneProcessInfoCommand(connection: ExeConnection, paneId: string): string { return `${herdrPrefix(connection)} && ${herdrBinary(connection)} pane process-info --pane ${shellAtom(paneId)}`; } export function validateWorktreeLeaseCommand(connection: ExeConnection, runPath: string, lease: string): string { return `${herdrPrefix(connection)} && test -d ${shellAtom(runPath)} && test "$(cat ${shellAtom(`${runPath}/.factorize-lease`)})" = ${shellAtom(lease)}`; } export function renameAgentCommand(connection: ExeConnection, currentName: string, expectedName: string): string { return `${herdrPrefix(connection)} && ${herdrBinary(connection)} agent rename ${shellAtom(currentName)} ${shellAtom(expectedName)} && ${herdrBinary(connection)} agent get ${shellAtom(expectedName)}`; } -export function startAgentInPaneCommand(agentName: string, connection: ExeConnection, paneId: string, cwd = connection.cwd): string { return `${herdrPrefix(connection)} && cd ${shellAtom(cwd)} && ${herdrBinary(connection)} agent start ${shellAtom(agentName)} --kind ${shellAtom(connection.agentKind)} --pane ${shellAtom(paneId)}${agentCommand(connection)} && ${herdrBinary(connection)} agent get ${shellAtom(agentName)}`; } +export function startAgentInPaneCommand(agentName: string, connection: ExeConnection, paneId: string, cwd = connection.cwd): string { return `${herdrPrefix(connection)} && cd ${shellAtom(cwd)} && ${herdrBinary(connection)} agent start ${shellAtom(agentName)} --kind ${shellAtom(connection.agentKind)} --pane ${shellAtom(paneId)}${agentCommand(connection, undefined, cwd)} && ${herdrBinary(connection)} agent get ${shellAtom(agentName)}`; } /** Stop only the foreground process group observed in the exact persisted pane. * Every signal is preceded by a fresh identity read, preventing stale-PID races. */ export function replaceForegroundCommand(agentName: string, connection: ExeConnection, paneId: string, cwd = connection.cwd): string { const herdr = herdrBinary(connection), pane = shellAtom(paneId); const fields = `jq -er --arg pane ${pane} --arg cwd ${shellAtom(cwd)} '[.result.pane_id // .pane_id // .result.pane.pane_id, .result.foreground.pid // .result.pid // .pid, .result.foreground.process_group // .result.foreground.pgid // .result.process_group // .result.pgid // .pgid, .result.foreground.cwd // .result.cwd // .cwd] | select(.[0] == $pane and .[1] > 1 and .[2] > 1 and .[3] == $cwd) | @tsv'`; - return [herdrPrefix(connection), `first=$(${herdr} pane process-info --pane ${pane} | ${fields})`, `first_pid=$(printf '%s' "$first" | cut -f2)`, `first_pgid=$(printf '%s' "$first" | cut -f3)`, `kill -INT -- "-$first_pgid"`, `i=0; while [ "$i" -lt 10 ] && kill -0 -- "-$first_pgid" 2>/dev/null; do i=$((i+1)); sleep 1; done`, `if kill -0 -- "-$first_pgid" 2>/dev/null; then second=$(${herdr} pane process-info --pane ${pane} | ${fields}) && second_pid=$(printf '%s' "$second" | cut -f2) && second_pgid=$(printf '%s' "$second" | cut -f3) && [ "$second_pid" = "$first_pid" ] && [ "$second_pgid" = "$first_pgid" ] && kill -TERM -- "-$first_pgid"; fi`, `i=0; while [ "$i" -lt 10 ] && kill -0 -- "-$first_pgid" 2>/dev/null; do i=$((i+1)); sleep 1; done`, `! kill -0 -- "-$first_pgid" 2>/dev/null`, `cd ${shellAtom(cwd)}`, `${herdr} agent start ${shellAtom(agentName)} --kind ${shellAtom(connection.agentKind)} --pane ${pane}${agentCommand(connection)}`, `${herdr} agent get ${shellAtom(agentName)}`].join(" && "); + return [herdrPrefix(connection), `first=$(${herdr} pane process-info --pane ${pane} | ${fields})`, `first_pid=$(printf '%s' "$first" | cut -f2)`, `first_pgid=$(printf '%s' "$first" | cut -f3)`, `kill -INT -- "-$first_pgid"`, `i=0; while [ "$i" -lt 10 ] && kill -0 -- "-$first_pgid" 2>/dev/null; do i=$((i+1)); sleep 1; done`, `if kill -0 -- "-$first_pgid" 2>/dev/null; then second=$(${herdr} pane process-info --pane ${pane} | ${fields}) && second_pid=$(printf '%s' "$second" | cut -f2) && second_pgid=$(printf '%s' "$second" | cut -f3) && [ "$second_pid" = "$first_pid" ] && [ "$second_pgid" = "$first_pgid" ] && kill -TERM -- "-$first_pgid"; fi`, `i=0; while [ "$i" -lt 10 ] && kill -0 -- "-$first_pgid" 2>/dev/null; do i=$((i+1)); sleep 1; done`, `! kill -0 -- "-$first_pgid" 2>/dev/null`, `cd ${shellAtom(cwd)}`, `${herdr} agent start ${shellAtom(agentName)} --kind ${shellAtom(connection.agentKind)} --pane ${pane}${agentCommand(connection, undefined, cwd)}`, `${herdr} agent get ${shellAtom(agentName)}`].join(" && "); } export function agentStatusCommand(connection: ExeConnection, agentName: string): string { @@ -128,9 +141,12 @@ function herdrBinary(connection: ExeConnection): string { } /** Herdr owns the executable; it forwards these arguments after `--` to it. */ -function agentCommand(connection: ExeConnection): string { +function agentCommand(connection: ExeConnection, prompt?: string, cwd?: string): string { const command = connection.agentCommand?.trim() || defaultAgentCommand(connection.agentKind); - return command ? ` -- ${shellWords(command).map(shellAtom).join(" ")}` : ""; + const args = command ? shellWords(command).map(shellAtom) : []; + if (connection.agentKind === "codex" && cwd) args.push("--dangerously-bypass-hook-trust", "-c", shellAtom(`projects.${JSON.stringify(cwd)}.trust_level="trusted"`)); + if (prompt !== undefined) args.push("--", shellAtom(prompt)); + return args.length ? ` -- ${args.join(" ")}` : ""; } export function defaultAgentCommand(agentKind: string): string { diff --git a/src/execution.ts b/src/execution.ts index f1e9829..f09f600 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -14,6 +14,7 @@ export interface LaunchRequest { workspaceName: string; runPath: string; lease: string; + prompt: string; } export interface RunHandle { diff --git a/src/tenant.ts b/src/tenant.ts index 5128136..50cb281 100644 --- a/src/tenant.ts +++ b/src/tenant.ts @@ -591,7 +591,9 @@ export class Tenant extends DurableObject { this.ctx.storage.sql.exec("UPDATE runs SET herdr_server_namespace='default',worktree_path=?,ownership_lease=?,ownership_generation=ownership_generation+1,agent_session_generation=1,updated_at=? WHERE id=?", worktreePath, lease, now(), run.id); if (String(run.provider || "linear") === "linear") await this.safeLinearComment(String(run.issue_id), `Factorize started **${connection.agentKind}** on ${connection.vmName} in Herdr workspace \`${workspaceName}\` for this issue.`); const backend = new ExeHerdrBackend(connection); - const launched = await backend.launch({ runId: String(run.id), agentName: String(run.agent_name), workspaceName, runPath: worktreePath, lease }); + const prompt = await decrypt(String(run.prompt), this.env.CREDENTIAL_ENCRYPTION_KEY); + this.ctx.storage.sql.exec("UPDATE runs SET prompt_delivery_state='submitting',updated_at=? WHERE id=?", now(), run.id); + const launched = await backend.launch({ runId: String(run.id), agentName: String(run.agent_name), workspaceName, runPath: worktreePath, lease, prompt }); const result = launched.command; const execRequest = await encrypt(result.requestBody, this.env.CREDENTIAL_ENCRYPTION_KEY); const execResponse = await encrypt(result.body, this.env.CREDENTIAL_ENCRYPTION_KEY); @@ -605,15 +607,8 @@ export class Tenant extends DurableObject { } const identity = parseAgent(verification.body); if (!identity) return this.beginRecovery(run, pipe, connection, "agent start succeeded but its structured identity was inconsistent"); - this.persistIdentity(run.id, identity, "starting"); - const prompt = await decrypt(String(run.prompt), this.env.CREDENTIAL_ENCRYPTION_KEY); - this.ctx.storage.sql.exec("UPDATE runs SET prompt_delivery_state='submitting',updated_at=? WHERE id=?", now(), run.id); - const delivery = await backend.deliverPrompt(launched.handle, prompt); - const deliveryRequest = await encrypt(delivery.command.requestBody, this.env.CREDENTIAL_ENCRYPTION_KEY); - const deliveryResponse = await encrypt(delivery.command.body, this.env.CREDENTIAL_ENCRYPTION_KEY); - this.ctx.storage.sql.exec("UPDATE runs SET prompt_delivery_state=?,prompt_delivery_request=?,prompt_delivery_response=?,prompt_delivery_status=?,prompt_delivery_exit_code=?,prompt_accepted=?,updated_at=? WHERE id=?", delivery.state, deliveryRequest, deliveryResponse, delivery.command.status, delivery.command.exitCode, delivery.state === "accepted" ? 1 : 0, now(), run.id); - this.commandActivity(run.id, "initial prompt delivery", delivery.command); - if (delivery.state !== "accepted") return this.finishRun(run, "failed", `Harness launched, but prompt delivery was ${delivery.state} (exe.dev HTTP ${delivery.command.status}, VM exit ${delivery.command.exitCode ?? "not reported"}).`); + this.ctx.storage.sql.exec("UPDATE runs SET prompt_delivery_state='accepted',prompt_delivery_request=?,prompt_delivery_response=?,prompt_delivery_status=?,prompt_delivery_exit_code=?,prompt_accepted=1,updated_at=? WHERE id=?", execRequest, execResponse, result.status, result.exitCode, now(), run.id); + this.activity(run.id, "prompt_delivered_at_launch", "The initial prompt was passed as a positional harness argument in the successful Herdr launch command."); this.persistIdentity(run.id, identity, "running"); } diff --git a/test/agent-launch.e2e.test.ts b/test/agent-launch.e2e.test.ts new file mode 100644 index 0000000..5463155 --- /dev/null +++ b/test/agent-launch.e2e.test.ts @@ -0,0 +1,50 @@ +import { chmod, mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { execFile } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { launchAgentCommand, type ExeConnection } from "../src/exe"; + +const runFile = promisify(execFile); +const fixture = resolve(dirname(fileURLToPath(import.meta.url)), "fixtures/fake-herdr.sh"); +const jsonLines = (output: string) => output.trim().split("\n").map(line => JSON.parse(line)); + +describe("agent launch and prompt delivery", () => { + it("delivers the exact prompt as part of the fake agent launch", async () => { + const root = await mkdtemp(`${tmpdir()}/factorize-agent-e2e-`); + try { + const repository = resolve(root, "repo"); + const runPath = resolve(repository, ".factorize-runs/run-e2e"); + const stateDir = resolve(root, "fake-herdr-state"); + const promptDirectory = "/tmp/factorize-prompts/run-e2e"; + await mkdir(repository, { recursive: true }); + await chmod(fixture, 0o755); + + const connection: ExeConnection = { + vmName: "unused", + apiToken: "unused", + agentKind: "codex", + cwd: repository, + herdrCommand: fixture, + }; + const environment = { + ...process.env, + FAKE_HERDR_STATE_DIR: stateDir, + FAKE_HERDR_RUN_PATH: runPath, + }; + + const prompt = "Fix the launch race.\n\nConfirm readiness before starting."; + const launched = await runFile("bash", ["-c", launchAgentCommand("run-e2e", connection, "flow-e2e", runPath, "lease-e2e", prompt)], { env: environment }); + expect(jsonLines(launched.stdout).at(-1)?.result.agent).toMatchObject({ name: "run-e2e", agent_status: "working", interactive_ready: true }); + await expect(readFile(resolve(promptDirectory, "prompt.md"), "utf8")).resolves.toBe(prompt); + await expect(readFile(resolve(stateDir, "launch-instruction"), "utf8")).resolves.toBe(`Read and follow the complete task instructions in ${promptDirectory}/prompt.md`); + await expect(readFile(resolve(stateDir, "status"), "utf8")).resolves.toBe("working"); + await expect(readFile(resolve(stateDir, "events"), "utf8")).resolves.toBe("workspace list\nworkspace create\ntab rename\nagent get\nagent start\nagent get\n"); + } finally { + await rm(root, { recursive: true, force: true }); + await rm("/tmp/factorize-prompts/run-e2e", { recursive: true, force: true }); + } + }); +}); diff --git a/test/execution-backend.test.ts b/test/execution-backend.test.ts index 0f5f140..91f8ffd 100644 --- a/test/execution-backend.test.ts +++ b/test/execution-backend.test.ts @@ -12,8 +12,10 @@ describe("ExeHerdrBackend", () => { return new Response(`{\"result\":{\"agent\":{\"name\":\"run-1\",\"agent_status\":\"working\"}}}\n${marker}:0\n`); })); const backend = new ExeHerdrBackend({ vmName: "vm", apiToken: "secret", agentKind: "codex", cwd: "/repo" }); - const launched = await backend.launch({ runId: "run-1", agentName: "run-1", workspaceName: "flow", runPath: "/repo/.factorize-runs/run-1", lease: "lease" }); + const launched = await backend.launch({ runId: "run-1", agentName: "run-1", workspaceName: "flow", runPath: "/repo/.factorize-runs/run-1", lease: "lease", prompt: "do the work" }); expect(requests[0]).toContain("agent start"); + expect(requests[0]).toContain("ZG8gdGhlIHdvcms="); + expect(requests[0]).toContain("Read and follow the complete task instructions in /tmp/factorize-prompts/run-1/prompt.md"); expect(requests[0]).not.toContain("agent prompt"); const delivered = await backend.deliverPrompt(launched.handle, "do the work"); expect(delivered.state).toBe("accepted"); diff --git a/test/fixtures/fake-herdr.sh b/test/fixtures/fake-herdr.sh new file mode 100755 index 0000000..91cc594 --- /dev/null +++ b/test/fixtures/fake-herdr.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +state_dir=${FAKE_HERDR_STATE_DIR:?FAKE_HERDR_STATE_DIR is required} +mkdir -p "$state_dir" +printf '%s %s\n' "${1:-}" "${2:-}" >> "$state_dir/events" + +agent_json() { + local status + status=$(cat "$state_dir/status") + printf '{"result":{"agent":{"name":"%s","agent":"codex","agent_status":"%s","interactive_ready":true,"cwd":"%s","workspace_id":"w-test","tab_id":"w-test:t1","pane_id":"w-test:p1","terminal_id":"term-test"}}}\n' \ + "$(cat "$state_dir/name")" "$status" "$FAKE_HERDR_RUN_PATH" +} + +case "${1:-} ${2:-}" in + "workspace list") + printf '{"result":{"workspaces":[]}}\n' + ;; + "workspace create") + printf '{"result":{"workspace":{"workspace_id":"w-test"},"tab":{"tab_id":"w-test:t1"},"root_pane":{"pane_id":"w-test:p1"}}}\n' + ;; + "tab rename") + printf '{"result":{"tab":{"tab_id":"w-test:t1"}}}\n' + ;; + "agent get") + test -f "$state_dir/status" + agent_json + ;; + "agent start") + name=${3:?agent name is required} + printf '%s' "$name" > "$state_dir/name" + printf '%s' "${@: -1}" > "$state_dir/launch-instruction" + printf working > "$state_dir/status" + agent_json + ;; + "agent wait") + test "$(cat "$state_dir/status")" = idle + agent_json + ;; + "agent prompt") + test "$(cat "$state_dir/status")" = idle + printf '%s' "${4-}" > "$state_dir/prompt" + printf working > "$state_dir/status" + agent_json + ;; + *) + printf 'unsupported fake Herdr command: %q ' "$@" >&2 + printf '\n' >&2 + exit 2 + ;; +esac diff --git a/test/workspace.test.ts b/test/workspace.test.ts index 9a2e27c..dc03073 100644 --- a/test/workspace.test.ts +++ b/test/workspace.test.ts @@ -37,18 +37,26 @@ describe("flow workspaces", () => { it("keeps harness launch separate from prompt delivery", () => { const connection = { vmName: "vm", apiToken: "token", agentKind: "codex", cwd: "/repo" }; - const launch = launchAgentCommand("factorize-1", connection, "factorize", "/repo/.factorize-runs/run", "lease"); + const launch = launchAgentCommand("factorize-1", connection, "factorize", "/repo/.factorize-runs/run", "lease", "fix it"); const delivery = promptAgentCommand(connection, "factorize-1", "fix it"); expect(launch).toContain("agent start 'factorize-1'"); + expect(launch).toContain("/tmp/factorize-prompts/factorize-1/prompt.md"); + expect(launch).toContain("Read and follow the complete task instructions in"); + expect(launch).toContain("--dangerously-bypass-hook-trust"); + expect(launch).toContain('projects."/repo/.factorize-runs/run".trust_level="trusted"'); expect(launch).not.toContain("agent prompt"); expect(delivery).toContain("agent prompt 'factorize-1'"); + expect(delivery).toContain("agent wait 'factorize-1' --until idle --until done --timeout 15000"); + expect(delivery).toContain("agent prompt 'factorize-1' \"$prompt\" --wait --until working --until blocked --timeout 7000"); + expect(delivery).not.toContain("--until done --timeout 7000"); expect(delivery).not.toContain("agent start"); }); - it("never skips prompt delivery when an idempotent launch finds an agent", () => { + it("passes the initial prompt only when starting a new idempotent agent", () => { const command = startAgentCommand("factorize-1", { vmName: "vm", apiToken: "token", agentKind: "codex", cwd: "/repo" }, "fix it", "factorize", "/repo/.factorize-runs/run", "lease"); - expect(command.indexOf("agent prompt 'factorize-1'")).toBeGreaterThan(command.indexOf("existing=")); - expect(command).not.toMatch(/then printf[^;]+; else[^;]+agent prompt/); + expect(command).not.toContain("agent prompt"); + expect(command).toContain("else herdr agent start 'factorize-1'"); + expect(command).toContain("/tmp/factorize-prompts/factorize-1/prompt.md"); }); it("uses no-prompt defaults for Codex and Claude, but leaves Pi unchanged", () => { diff --git a/vitest.agent-e2e.config.ts b/vitest.agent-e2e.config.ts new file mode 100644 index 0000000..8b3b95d --- /dev/null +++ b/vitest.agent-e2e.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/agent-launch.e2e.test.ts"], + environment: "node", + }, +}); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index b34f5fe..42e054d 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -2,7 +2,7 @@ import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; export default defineWorkersConfig({ test: { - include: ["test/**/*.e2e.test.ts"], + include: ["test/device-oauth.e2e.test.ts"], poolOptions: { workers: { isolatedStorage: false,