import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert"; import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context"; import code from "../extensions/code/mod.ts"; import { ShellDoc, ShellEntry } from "./shell.ts"; import { setup, turn } from "./fixture.ts"; import { tempDir } from "@berry/util"; async function prepare() { const harness = await setup(); const temporary = await tempDir({ prefix: "user-shell-test-" }); const root = temporary.path; await harness.berry.install(code); const tid = await harness.berry.create({ extensions: { environment: { cwd: root }, code: {} }, model: harness.model, }); const conversation = (await harness.berry.conversation(tid))!; return { ...harness, root, tid, conversation, async entries() { return (await conversation.entries({ order: "ascending" }, 100, undefined, context)).items .filter(ShellEntry.is); }, async run(command: string, excluded = false) { const task = await harness.berry.shell(tid, command, excluded); const settled = await harness.berry.harness.waitForTask(task, context); assertEquals(settled.state.outcome.status, "completed"); await conversation.waitForIdle(context); }, async output() { for (let i = 0; i < 200; i++) { const run = (await harness.berry.harness.snapshot(ShellDoc, conversation.id, context))?.run; if ((run?.details as { rawOutput?: string } | null)?.rawOutput) return run!; await new Promise((resolve) => setTimeout(resolve, 10)); } throw new Error("Shell command did not produce live output"); }, async [Symbol.asyncDispose]() { await conversation.abort(context); await harness.berry.close(); await temporary[Symbol.asyncDispose](); }, }; } Deno.test("user shell commands persist output at the working directory without starting the model", async () => { await using h = await prepare(); await h.run("printf 'visible'; printf ' stderr' >&2; pwd; exit 7"); await h.run("printf 'private'", true); assertEquals(h.faux.state.callCount, 0); const entries = await h.entries(); assertEquals(entries.length, 2); const first = entries[0].data.result as { output: string; exitCode: number }; assertStringIncludes(first.output, "visible"); assertStringIncludes(first.output, " stderr"); assertStringIncludes(first.output, h.root); assertEquals(first.exitCode, 7); assertEquals(entries[0].data.status, "ok"); assertEquals(entries[1].model, undefined); assertEquals(entries[1].data.excludeFromContext, true); assertEquals(entries[1].data.result, { output: "private", exitCode: 0 }); const [messages] = await turn(h, h.tid, []); const text = JSON.stringify(messages); assertStringIncludes(text, "visible"); assertStringIncludes(text, "exit code 7"); assertEquals(text.includes("private"), false); const fork = await h.conversation.fork( entries[1].id, { ownership: { kind: "ownerless" } }, context, ); const forkTid = await h.berry.tidOf(fork.id); const [forkMessages] = await turn(h, forkTid, []); assertStringIncludes(JSON.stringify(forkMessages), "visible"); assertEquals(JSON.stringify(forkMessages).includes("private"), false); }); Deno.test("user shell output streams with ANSI intact, refuses concurrent commands and can be aborted", async () => { await using h = await prepare(); const task = await h.berry.shell(h.tid, "printf '\\033[31mred'; sleep 30"); const live = await h.output(); assertEquals(live.status, "running"); assertEquals(live.details, { rawOutput: "\x1b[31mred" }); await assertRejects(() => h.berry.shell(h.tid, "touch should-not-run"), Error, "Busy"); await h.conversation.abort(context); await h.berry.harness.waitForTask(task, context); const [entry] = await h.entries(); assertEquals(entry.data.status, "cancelled"); assertEquals(entry.data.details, live.details); assertStringIncludes(JSON.stringify(entry.model), "command cancelled"); assertEquals( (await h.berry.harness.snapshot(ShellDoc, h.conversation.id, context))?.run, undefined, ); assertEquals(h.faux.state.callCount, 0); await h.run("printf 'after cancellation'"); assertEquals((await h.entries()).length, 2); }); Deno.test("shell commands require enabled code, nonempty input and an idle assistant", async () => { await using h = await prepare(); let running: ReturnType | undefined; try { await assertRejects(() => h.berry.shell(h.tid, " \n "), Error, "Expected a shell command"); const disabled = await h.berry.create({ extensions: {} }); await assertRejects( () => h.berry.shell(disabled, "touch should-not-run"), Error, "code extension", ); await h.berry.configure(h.tid, { extensions: ["gate", "code"] }); const { fauxAssistantMessage, fauxToolCall } = await import( "@earendil-works/pi-ai/providers/faux" ); running = turn(h, h.tid, [ fauxAssistantMessage(fauxToolCall("hold", {}), { stopReason: "toolUse" }), ]); await h.gate.entered.promise; await assertRejects(() => h.berry.shell(h.tid, "touch should-not-run"), Error, "Busy"); h.gate.release.resolve("done"); await running; assertEquals(await h.entries(), []); } finally { h.gate.release.resolve("done"); await running; } }); Deno.test("a restart records interrupted shell execution instead of replaying its side effects", async () => { const { openNodeSqliteStorage } = await import("@earendil-works/pi-durable/storage/sqlite/node"); const { createModels } = await import("@earendil-works/pi-ai/models"); const { fauxProvider } = await import("@earendil-works/pi-ai/providers/faux"); const { openBerry } = await import("./berry.ts"); const models = createModels(); models.setProvider(fauxProvider().provider); await using rootDir = await tempDir({ prefix: "shell-restart-test-" }); const root = rootDir.path; const path = `${root}/berry.sqlite`; let berry = await openBerry(await openNodeSqliteStorage(path), models); try { await berry.install(code); const tid = await berry.create({ extensions: { environment: { cwd: root }, code: {} } }); const conversation = (await berry.conversation(tid))!; await berry.shell(tid, "echo effect >> effects; printf started; sleep 30"); for (let i = 0; i < 200; i++) { const run = (await berry.harness.snapshot(ShellDoc, conversation.id, context))?.run; if ((run?.details as { rawOutput?: string } | null)?.rawOutput === "started") break; if (i === 199) throw new Error("Command never started"); await new Promise((resolve) => setTimeout(resolve, 10)); } await berry.close(); berry = await openBerry(await openNodeSqliteStorage(path), models); await berry.install(code); await berry.harness.waitForIdle(context); const reopened = (await berry.conversation(tid))!; const entries = (await reopened.entries({ order: "ascending" }, 100, undefined, context)).items; const shell = entries.filter(ShellEntry.is); assertEquals(shell.length, 1); assertEquals(shell[0].data.status, "error"); assertStringIncludes(shell[0].data.error!, "not rerun"); assertEquals(await Deno.readTextFile(`${root}/effects`), "effect\n"); assertEquals((await berry.harness.snapshot(ShellDoc, reopened.id, context))?.run, undefined); } finally { await berry.close(); } });