Files
openclaw/src/agents/bash-tools.exec-runtime.test.ts
Peter Steinberger f91de52f0d refactor: move runtime state to SQLite
* refactor: remove stale file-backed shims

* fix: harden sqlite state ci boundaries

* refactor: store matrix idb snapshots in sqlite

* fix: satisfy rebased CI guardrails

* refactor: store current conversation bindings in sqlite table

* refactor: store tui last sessions in sqlite table

* refactor: reset sqlite schema history

* refactor: drop unshipped sqlite table migration

* refactor: remove plugin index file rollback

* refactor: drop unshipped sqlite sidecar migrations

* refactor: remove runtime commitments kv migration

* refactor: preserve kysely sync result types

* refactor: drop unshipped sqlite schema migration table

* test: keep session usage coverage sqlite-backed

* refactor: keep sqlite migration doctor-only

* refactor: isolate device legacy imports

* refactor: isolate push voicewake legacy imports

* refactor: isolate remaining runtime legacy imports

* refactor: tighten sqlite migration guardrails

* test: cover sqlite persisted enum parsing

* refactor: isolate legacy update and tui imports

* refactor: tighten sqlite state ownership

* refactor: move legacy imports behind doctor

* refactor: remove legacy session row lookup

* refactor: canonicalize memory transcript locators

* refactor: drop transcript path scope fallbacks

* refactor: drop runtime legacy session delivery pruning

* refactor: store tts prefs only in sqlite

* refactor: remove cron store path runtime

* refactor: use cron sqlite store keys

* refactor: rename telegram message cache scope

* refactor: read memory dreaming status from sqlite

* refactor: rename cron status store key

* refactor: stop remembering transcript file paths

* test: use sqlite locators in agent fixtures

* refactor: remove file-shaped commitments and cron store surfaces

* refactor: keep compaction transcript handles out of session rows

* refactor: derive transcript handles from session identity

* refactor: derive runtime transcript handles

* refactor: remove gateway session locator reads

* refactor: remove transcript locator from session rows

* refactor: store raw stream diagnostics in sqlite

* refactor: remove file-shaped transcript rotation

* refactor: hide legacy trajectory paths from runtime

* refactor: remove runtime transcript file bridges

* refactor: repair database-first rebase fallout

* refactor: align tests with database-first state

* refactor: remove transcript file handoffs

* refactor: sync post-compaction memory by transcript scope

* refactor: run codex app-server sessions by id

* refactor: bind codex runtime state by session id

* refactor: pass memory transcripts by sqlite scope

* refactor: remove transcript locator cleanup leftovers

* test: remove stale transcript file fixtures

* refactor: remove transcript locator test helper

* test: make cron sqlite keys explicit

* test: remove cron runtime store paths

* test: remove stale session file fixtures

* test: use sqlite cron keys in diagnostics

* refactor: remove runtime delivery queue backfill

* test: drop fake export session file mocks

* refactor: rename acp session read failure flag

* refactor: rename acp row session key

* refactor: remove session store test seams

* refactor: move legacy session parser tests to doctor

* refactor: reindex managed memory in place

* refactor: drop stale session store wording

* refactor: rename session row helpers

* refactor: rename sqlite session entry modules

* refactor: remove transcript locator leftovers

* refactor: trim file-era audit wording

* refactor: clean managed media through sqlite

* fix: prefer explicit agent for exports

* fix: use prepared agent for session resets

* fix: canonicalize legacy codex binding import

* test: rename state cleanup helper

* docs: align backup docs with sqlite state

* refactor: drop legacy Pi usage auth fallback

* refactor: move legacy auth profile imports to doctor

* refactor: keep Pi model discovery auth in memory

* refactor: remove MSTeams legacy learning key fallback

* refactor: store model catalog config in sqlite

* refactor: use sqlite model catalog at runtime

* refactor: remove model json compatibility aliases

* refactor: store auth profiles in sqlite

* refactor: seed copied auth profiles in sqlite

* refactor: make auth profile runtime sqlite-addressed

* refactor: migrate hermes secrets into sqlite auth store

* refactor: move plugin install config migration to doctor

* refactor: rename plugin index audit checks

* test: drop auth file assumptions

* test: remove legacy transcript file assertions

* refactor: drop legacy cli session aliases

* refactor: store skill uploads in sqlite

* refactor: keep subagent attachments in sqlite vfs

* refactor: drop subagent attachment cleanup state

* refactor: move legacy session aliases to doctor

* refactor: require node 24 for sqlite state runtime

* refactor: move provider caches into sqlite state

* fix: harden virtual agent filesystem

* refactor: enforce database-first runtime state

* refactor: rename compaction transcript rotation setting

* test: clean sqlite refactor test types

* refactor: consolidate sqlite runtime state

* refactor: model session conversations in sqlite

* refactor: stop deriving cron delivery from session keys

* refactor: stop classifying sessions from key shape

* refactor: hydrate announce targets from typed delivery

* refactor: route heartbeat delivery from typed sqlite context

* refactor: tighten typed sqlite session routing

* refactor: remove session origin routing shadow

* refactor: drop session origin shadow fixtures

* perf: query sqlite vfs paths by prefix

* refactor: use typed conversation metadata for sessions

* refactor: prefer typed session routing metadata

* refactor: require typed session routing metadata

* refactor: resolve group tool policy from typed sessions

* refactor: delete dead session thread info bridge

* Show Codex subscription reset times in channel errors (#80456)

* feat(plugin-sdk): consolidate session workflow APIs

* fix(agents): allow read-only agent mount reads

* [codex] refresh plugin regression fixtures

* fix(agents): restore compaction gateway logs

* test: tighten gateway startup assertions

* Redact persisted secret-shaped payloads [AI] (#79006)

* test: tighten device pair notify assertions

* test: tighten hermes secret assertions

* test: assert matrix client error shapes

* test: assert config compat warnings

* fix(heartbeat): remap cron-run exec events to session keys (#80214)

* fix(codex): route btw through native side threads

* fix(auth): accept friendly OpenAI order for Codex profiles

* fix(codex): rotate auth profiles inside harness

* fix: keep browser status page probe within timeout

* test: assert agents add outputs

* test: pin cron read status

* fix(agents): avoid Pi resource discovery stalls

Co-authored-by: dataCenter430 <titan032000@gmail.com>

* fix: retire timed-out codex app-server clients

* test: tighten qa lab runtime assertions

* test: check security fix outputs

* test: verify extension runtime messages

* feat(wake): expose typed sessionKey on wake protocol + system event CLI

* fix(gateway): await session_end during shutdown drain and track channel + compaction lifecycle paths (#57790)

* test: guard talk consult call helper

* fix(codex): scale context engine projection (#80761)

* fix(codex): scale context engine projection

* fix: document Codex context projection scaling

* fix: document Codex context projection scaling

* fix: document Codex context projection scaling

* fix: document Codex context projection scaling

* chore: align Codex projection changelog

* chore: realign Codex projection changelog

* fix: isolate Codex projection patch

---------

Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org>
Co-authored-by: Josh Lehman <josh@martian.engineering>

* refactor: move agent runtime state toward piless

* refactor: remove cron session reaper

* refactor: move session management to sqlite

* refactor: finish database-first state migration

* chore: refresh generated sqlite db types

* refactor: remove stale file-backed shims

* test: harden kysely type coverage

# Conflicts:
#	.agents/skills/kysely-database-access/SKILL.md
#	src/infra/kysely-sync.types.test.ts
#	src/proxy-capture/store.sqlite.test.ts
#	src/state/openclaw-agent-db.test.ts
#	src/state/openclaw-state-db.test.ts

* refactor: remove cron store path runtime

* refactor: keep compaction transcript handles out of session rows

* refactor: derive embedded transcripts from sqlite identity

* refactor: remove embedded transcript locator handoff

* refactor: remove runtime transcript file bridges

* refactor: remove transcript file handoffs

* refactor: remove MSTeams legacy learning key fallback

* refactor: store model catalog config in sqlite

* refactor: use sqlite model catalog at runtime

# Conflicts:
#	docs/cli/secrets.md
#	docs/gateway/authentication.md
#	docs/gateway/secrets.md

* fix: keep oauth sibling sync sqlite-local

# Conflicts:
#	src/commands/onboard-auth.test.ts

* refactor: remove task session store maintenance

# Conflicts:
#	src/commands/tasks.ts

* refactor: keep diagnostics in state sqlite

* refactor: enforce database-first runtime state

* refactor: consolidate sqlite runtime state

* Show Codex subscription reset times in channel errors (#80456)

* fix(codex): refresh subscription limit resets

* fix(codex): format reset times for channels

* Update CHANGELOG with latest changes and fixes

Updated CHANGELOG with recent fixes and improvements.

* fix(codex): keep command load failures on codex surface

* fix(codex): format account rate limits as rows

* fix(codex): summarize account limits as usage status

* fix(codex): simplify account limit status

* test: tighten subagent announce queue assertion

* test: tighten session delete lifecycle assertions

* test: tighten cron ops assertions

* fix: track cron execution milestones

* test: tighten hermes secret assertions

* test: assert matrix sync store payloads

* test: assert config compat warnings

* fix(codex): align btw side thread semantics

* fix(codex): honor codex fallback blocking

* fix(agents): avoid Pi resource discovery stalls

* test: tighten codex event assertions

* test: tighten cron assertions

* Fix Codex app-server OAuth harness auth

* refactor: move agent runtime state toward piless

* refactor: move device and push state to sqlite

* refactor: move runtime json state imports to doctor

* refactor: finish database-first state migration

* chore: refresh generated sqlite db types

* refactor: clarify cron sqlite store keys

* refactor: remove stale file-backed shims

* refactor: bind codex runtime state by session id

* test: expect sqlite trajectory branch export

* refactor: rename session row helpers

* fix: keep legacy device identity import in doctor

* refactor: enforce database-first runtime state

* refactor: consolidate sqlite runtime state

* build: align pi contract wrappers

* chore: repair database-first rebase

* refactor: remove session file test contracts

* test: update gateway session expectations

* refactor: stop routing from session compatibility shadows

* refactor: stop persisting session route shadows

* refactor: use typed delivery context in clients

* refactor: stop echoing session route shadows

* refactor: repair embedded runner rebase imports

# Conflicts:
#	src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts

* refactor: align pi contract imports

* refactor: satisfy kysely sync helper guard

* refactor: remove file transcript bridge remnants

* refactor: remove session locator compatibility

* refactor: remove session file test contracts

* refactor: keep rebase database-first clean

* refactor: remove session file assumptions from e2e

* docs: clarify database-first goal state

* test: remove legacy store markers from sqlite runtime tests

* refactor: remove legacy store assumptions from runtime seams

* refactor: align sqlite runtime helper seams

* test: update memory recall sqlite audit mock

* refactor: align database-first runtime type seams

* test: clarify doctor cron legacy store names

* fix: preserve sqlite session route projections

* test: fix copilot token cache test syntax

* docs: update database-first proof status

* test: align database-first test fixtures

* docs: update database-first proof status

* refactor: clean extension database-first drift

* test: align agent session route proof

* test: clarify doctor legacy path fixtures

* chore: clean database-first changed checks

* chore: repair database-first rebase markers

* build: allow baileys git subdependency

* chore: repair exp-vfs rebase drift

* chore: finish exp-vfs rebase cleanup

* chore: satisfy rebase lint drift

* chore: fix qqbot rebase type seam

* chore: fix rebase drift leftovers

* fix: keep auth profile oauth secrets out of sqlite

* fix: repair rebase drift tests

* test: stabilize pairing request ordering

* test: use source manifests in plugin contract checks

* fix: restore gateway session metadata after rebase

* fix: repair database-first rebase drift

* fix: clean up database-first rebase fallout

* test: stabilize line quick reply receipt time

* fix: repair extension rebase drift

* test: keep transcript redaction tests sqlite-backed

* fix: carry injected transcript redaction through sqlite

* chore: clean database branch rebase residue

* fix: repair database branch CI drift

* fix: repair database branch CI guard drift

* fix: stabilize oauth tls preflight test

* test: align database branch fast guards

* test: repair build artifact boundary guards

* chore: clean changelog rebase markers

---------

Co-authored-by: pashpashpash <nik@vault77.ai>
Co-authored-by: Eva <eva@100yen.org>
Co-authored-by: stainlu <stainlu@newtype-ai.org>
Co-authored-by: Jason Zhou <jason.zhou.design@gmail.com>
Co-authored-by: Ruben Cuevas <hi@rubencu.com>
Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com>
Co-authored-by: Shakker <shakkerdroid@gmail.com>
Co-authored-by: Kaspre <36520309+Kaspre@users.noreply.github.com>
Co-authored-by: dataCenter430 <titan032000@gmail.com>
Co-authored-by: Kaspre <kaspre@gmail.com>
Co-authored-by: pandadev66 <nova.full.stack@outlook.com>
Co-authored-by: Eva <admin@100yen.org>
Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org>
Co-authored-by: Josh Lehman <josh@martian.engineering>
Co-authored-by: jeffjhunter <support@aipersonamethod.com>
2026-05-13 13:15:12 +01:00

687 lines
21 KiB
TypeScript

import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const requestHeartbeatMock = vi.hoisted(() => vi.fn());
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
const supervisorMock = vi.hoisted(() => ({
spawn: vi.fn(),
}));
vi.mock("../infra/heartbeat-wake.js", () => ({
requestHeartbeat: requestHeartbeatMock,
}));
vi.mock("../infra/system-events.js", () => ({
enqueueSystemEvent: enqueueSystemEventMock,
}));
vi.mock("../process/supervisor/index.js", () => ({
getProcessSupervisor: () => ({
spawn: supervisorMock.spawn,
}),
}));
let markBackgrounded: typeof import("./bash-process-registry.js").markBackgrounded;
let buildExecExitOutcome: typeof import("./bash-tools.exec-runtime.js").buildExecExitOutcome;
let detectCursorKeyMode: typeof import("./bash-tools.exec-runtime.js").detectCursorKeyMode;
let emitExecSystemEvent: typeof import("./bash-tools.exec-runtime.js").emitExecSystemEvent;
let formatExecFailureReason: typeof import("./bash-tools.exec-runtime.js").formatExecFailureReason;
let renderExecUpdateText: typeof import("./bash-tools.exec-runtime.js").renderExecUpdateText;
let resolveExecTarget: typeof import("./bash-tools.exec-runtime.js").resolveExecTarget;
let runExecProcess: typeof import("./bash-tools.exec-runtime.js").runExecProcess;
beforeAll(async () => {
({ markBackgrounded } = await import("./bash-process-registry.js"));
({
buildExecExitOutcome,
detectCursorKeyMode,
emitExecSystemEvent,
formatExecFailureReason,
renderExecUpdateText,
resolveExecTarget,
runExecProcess,
} = await import("./bash-tools.exec-runtime.js"));
});
beforeEach(() => {
requestHeartbeatMock.mockClear();
enqueueSystemEventMock.mockClear();
supervisorMock.spawn.mockReset();
});
function expectExecTarget(
actual: ReturnType<typeof resolveExecTarget>,
expected: {
configuredTarget: string;
requestedTarget: string | null;
selectedTarget: string;
effectiveHost: string;
},
) {
expect(actual.configuredTarget).toBe(expected.configuredTarget);
expect(actual.requestedTarget).toBe(expected.requestedTarget);
expect(actual.selectedTarget).toBe(expected.selectedTarget);
expect(actual.effectiveHost).toBe(expected.effectiveHost);
}
function requireSystemEventCall(): [string, Record<string, unknown>] {
const call = enqueueSystemEventMock.mock.calls[0];
if (!call) {
throw new Error("expected system event call");
}
return call as [string, Record<string, unknown>];
}
function requireHeartbeatCall(): Record<string, unknown> {
const call = requestHeartbeatMock.mock.calls[0];
if (!call) {
throw new Error("expected heartbeat call");
}
return call[0] as Record<string, unknown>;
}
describe("detectCursorKeyMode", () => {
it("returns null when no toggle found", () => {
expect(detectCursorKeyMode("hello world")).toBe(null);
expect(detectCursorKeyMode("")).toBe(null);
});
it("detects smkx (application mode)", () => {
expect(detectCursorKeyMode("\x1b[?1h")).toBe("application");
expect(detectCursorKeyMode("\x1b[?1h\x1b=")).toBe("application");
expect(detectCursorKeyMode("before \x1b[?1h after")).toBe("application");
});
it("detects rmkx (normal mode)", () => {
expect(detectCursorKeyMode("\x1b[?1l")).toBe("normal");
expect(detectCursorKeyMode("\x1b[?1l\x1b>")).toBe("normal");
expect(detectCursorKeyMode("before \x1b[?1l after")).toBe("normal");
});
it("last toggle wins when both present", () => {
// smkx first, then rmkx - should be normal
expect(detectCursorKeyMode("\x1b[?1h\x1b[?1l")).toBe("normal");
// rmkx first, then smkx - should be application
expect(detectCursorKeyMode("\x1b[?1l\x1b[?1h")).toBe("application");
// Multiple toggles - last one wins
expect(detectCursorKeyMode("\x1b[?1h\x1b[?1l\x1b[?1h")).toBe("application");
});
});
describe("resolveExecTarget", () => {
it("keeps implicit auto on sandbox when a sandbox runtime is available", () => {
expectExecTarget(
resolveExecTarget({
configuredTarget: "auto",
elevatedRequested: false,
sandboxAvailable: true,
}),
{
configuredTarget: "auto",
requestedTarget: null,
selectedTarget: "auto",
effectiveHost: "sandbox",
},
);
});
it("keeps implicit auto on gateway when no sandbox runtime is available", () => {
expectExecTarget(
resolveExecTarget({
configuredTarget: "auto",
elevatedRequested: false,
sandboxAvailable: false,
}),
{
configuredTarget: "auto",
requestedTarget: null,
selectedTarget: "auto",
effectiveHost: "gateway",
},
);
});
it("allows per-call host=node override when configured host is auto", () => {
expectExecTarget(
resolveExecTarget({
configuredTarget: "auto",
requestedTarget: "node",
elevatedRequested: false,
sandboxAvailable: false,
}),
{
configuredTarget: "auto",
requestedTarget: "node",
selectedTarget: "node",
effectiveHost: "node",
},
);
});
it("allows per-call host=gateway override when configured host is auto and no sandbox", () => {
expectExecTarget(
resolveExecTarget({
configuredTarget: "auto",
requestedTarget: "gateway",
elevatedRequested: false,
sandboxAvailable: false,
}),
{
configuredTarget: "auto",
requestedTarget: "gateway",
selectedTarget: "gateway",
effectiveHost: "gateway",
},
);
});
it("rejects per-call host=gateway override from auto when sandbox is available", () => {
expect(() =>
resolveExecTarget({
configuredTarget: "auto",
requestedTarget: "gateway",
elevatedRequested: false,
sandboxAvailable: true,
}),
).toThrow(
"exec host not allowed (requested gateway; configured host is auto; set tools.exec.host=gateway to allow this override).",
);
});
it("rejects per-call host=node override from auto when sandbox is available", () => {
expect(() =>
resolveExecTarget({
configuredTarget: "auto",
requestedTarget: "node",
elevatedRequested: false,
sandboxAvailable: true,
}),
).toThrow(
"exec host not allowed (requested node; configured host is auto; set tools.exec.host=node to allow this override).",
);
});
it("allows per-call host=sandbox override when configured host is auto", () => {
expectExecTarget(
resolveExecTarget({
configuredTarget: "auto",
requestedTarget: "sandbox",
elevatedRequested: false,
sandboxAvailable: true,
}),
{
configuredTarget: "auto",
requestedTarget: "sandbox",
selectedTarget: "sandbox",
effectiveHost: "sandbox",
},
);
});
it("rejects cross-host override when configured target is a concrete host", () => {
expect(() =>
resolveExecTarget({
configuredTarget: "node",
requestedTarget: "gateway",
elevatedRequested: false,
sandboxAvailable: false,
}),
).toThrow(
"exec host not allowed (requested gateway; configured host is node; set tools.exec.host=gateway or auto to allow this override).",
);
});
it("allows explicit auto request when configured host is auto", () => {
expectExecTarget(
resolveExecTarget({
configuredTarget: "auto",
requestedTarget: "auto",
elevatedRequested: false,
sandboxAvailable: true,
}),
{
configuredTarget: "auto",
requestedTarget: "auto",
selectedTarget: "auto",
effectiveHost: "sandbox",
},
);
});
it("requires an exact match for non-auto configured targets", () => {
expect(() =>
resolveExecTarget({
configuredTarget: "gateway",
requestedTarget: "auto",
elevatedRequested: false,
sandboxAvailable: true,
}),
).toThrow(
"exec host not allowed (requested auto; configured host is gateway; set tools.exec.host=auto to allow this override).",
);
});
it("allows exact node matches", () => {
expectExecTarget(
resolveExecTarget({
configuredTarget: "node",
requestedTarget: "node",
elevatedRequested: false,
sandboxAvailable: true,
}),
{
configuredTarget: "node",
requestedTarget: "node",
selectedTarget: "node",
effectiveHost: "node",
},
);
});
it("forces elevated requests onto the gateway host when configured target is auto", () => {
expectExecTarget(
resolveExecTarget({
configuredTarget: "auto",
requestedTarget: "sandbox",
elevatedRequested: true,
sandboxAvailable: true,
}),
{
configuredTarget: "auto",
requestedTarget: "sandbox",
selectedTarget: "gateway",
effectiveHost: "gateway",
},
);
});
it("keeps explicit node override under elevated requests when configured target is auto", () => {
expectExecTarget(
resolveExecTarget({
configuredTarget: "auto",
requestedTarget: "node",
elevatedRequested: true,
sandboxAvailable: false,
}),
{
configuredTarget: "auto",
requestedTarget: "node",
selectedTarget: "node",
effectiveHost: "node",
},
);
});
it("honours node target for elevated requests when configured target is node", () => {
expectExecTarget(
resolveExecTarget({
configuredTarget: "node",
requestedTarget: "node",
elevatedRequested: true,
sandboxAvailable: false,
}),
{
configuredTarget: "node",
requestedTarget: "node",
selectedTarget: "node",
effectiveHost: "node",
},
);
});
it("routes to node for elevated when configured=node and no per-call override", () => {
expectExecTarget(
resolveExecTarget({
configuredTarget: "node",
elevatedRequested: true,
sandboxAvailable: false,
}),
{
configuredTarget: "node",
requestedTarget: null,
selectedTarget: "node",
effectiveHost: "node",
},
);
});
it("rejects mismatched requestedTarget under elevated+node", () => {
expect(() =>
resolveExecTarget({
configuredTarget: "node",
requestedTarget: "gateway",
elevatedRequested: true,
sandboxAvailable: false,
}),
).toThrow(
"exec host not allowed (requested gateway; configured host is node; set tools.exec.host=gateway or auto to allow this override).",
);
});
});
describe("renderExecUpdateText", () => {
it("uses a non-empty placeholder when an exec update has no output", () => {
expect(renderExecUpdateText({ tailText: "", warnings: [] })).toBe("(no output)");
});
it("preserves non-empty exec output", () => {
expect(renderExecUpdateText({ tailText: "hello", warnings: [] })).toBe("hello");
});
it("keeps warnings while still avoiding empty output text", () => {
expect(renderExecUpdateText({ tailText: "", warnings: ["Warning: retrying"] })).toBe(
"Warning: retrying\n\n(no output)",
);
});
it("combines warnings with non-empty output", () => {
expect(renderExecUpdateText({ tailText: "hello", warnings: ["Warning: retrying"] })).toBe(
"Warning: retrying\n\nhello",
);
});
});
describe("exec notifyOnExit suppression", () => {
async function runBackgroundedExit(params: {
reason: "manual-cancel" | "overall-timeout";
stdout?: string;
}) {
supervisorMock.spawn.mockImplementationOnce(
async (input: { onStdout?: (chunk: string) => void }) => {
if (params.stdout) {
input.onStdout?.(params.stdout);
}
return {
runId: "run-1",
startedAtMs: Date.now(),
pid: 123,
wait: async () => {
await new Promise((resolve) => setImmediate(resolve));
return {
reason: params.reason,
exitCode: null,
exitSignal: "SIGKILL",
durationMs: 10,
stdout: "",
stderr: "",
timedOut: params.reason === "overall-timeout",
noOutputTimedOut: false,
};
},
cancel: vi.fn(),
};
},
);
const run = await runExecProcess({
command: "sleep 999",
workdir: "/tmp",
env: {},
usePty: false,
warnings: [],
maxOutput: 1000,
pendingMaxOutput: 1000,
notifyOnExit: true,
notifyOnExitEmptySuccess: false,
sessionKey: "agent:main:main",
timeoutSec: null,
});
markBackgrounded(run.session);
return await run.promise;
}
it("keeps manual-cancelled no-output background execs silent", async () => {
const outcome = await runBackgroundedExit({ reason: "manual-cancel" });
expect(outcome.status).toBe("failed");
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(requestHeartbeatMock).not.toHaveBeenCalled();
});
it("notifies for manual-cancelled background execs with output", async () => {
await runBackgroundedExit({ reason: "manual-cancel", stdout: "partial output\n" });
const [message, options] = requireSystemEventCall();
expect(message).toContain("partial output");
expect(options.sessionKey).toBe("agent:main:main");
expect(requestHeartbeatMock).toHaveBeenCalled();
});
it("still notifies for no-output background exec timeouts", async () => {
await runBackgroundedExit({ reason: "overall-timeout" });
const [message, options] = requireSystemEventCall();
expect(message).toContain("Exec failed");
expect(options.sessionKey).toBe("agent:main:main");
expect(requestHeartbeatMock).toHaveBeenCalled();
});
});
describe("emitExecSystemEvent", () => {
beforeEach(() => {
requestHeartbeatMock.mockClear();
enqueueSystemEventMock.mockClear();
});
it("scopes heartbeat wake to the event session key", () => {
emitExecSystemEvent("Exec finished", {
sessionKey: "agent:ops:main",
contextKey: "exec:run-1",
deliveryContext: {
channel: "telegram",
to: "telegram:-100123:topic:47",
threadId: 47,
},
});
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
sessionKey: "agent:ops:main",
contextKey: "exec:run-1",
deliveryContext: {
channel: "telegram",
to: "telegram:-100123:topic:47",
threadId: 47,
},
trusted: false,
});
const heartbeat = requireHeartbeatCall();
expect(heartbeat.coalesceMs).toBe(0);
expect(heartbeat.reason).toBe("exec-event");
expect(heartbeat.sessionKey).toBe("agent:ops:main");
});
it("remaps cron-run event enqueue and wake targets to the drained agent main session", () => {
emitExecSystemEvent("Exec finished", {
sessionKey: "agent:ops:cron:nightly:run:run-1",
contextKey: "exec:run-cron",
mainKey: "primary",
});
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
sessionKey: "agent:ops:primary",
contextKey: "exec:run-cron",
trusted: false,
});
expect(requestHeartbeatMock).toHaveBeenCalledTimes(1);
const [[heartbeatParams]] = requestHeartbeatMock.mock.calls as unknown as Array<
[{ coalesceMs?: number; reason?: string; sessionKey?: string }]
>;
expect(heartbeatParams.coalesceMs).toBe(0);
expect(heartbeatParams.reason).toBe("exec-event");
expect(heartbeatParams.sessionKey).toBe("agent:ops:primary");
});
it("routes global-scope cron-run events to the global queue and preserves the agent wake target", () => {
emitExecSystemEvent("Exec finished", {
sessionKey: "agent:ops:cron:nightly:run:run-1:subagent:worker",
contextKey: "exec:run-global",
sessionScope: "global",
});
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
sessionKey: "global",
contextKey: "exec:run-global",
trusted: false,
});
expect(requestHeartbeatMock).toHaveBeenCalledTimes(1);
const [[heartbeatParams]] = requestHeartbeatMock.mock.calls as unknown as Array<
[{ agentId?: string; coalesceMs?: number; reason?: string }]
>;
expect(heartbeatParams.agentId).toBe("ops");
expect(heartbeatParams.coalesceMs).toBe(0);
expect(heartbeatParams.reason).toBe("exec-event");
expect(requestHeartbeatMock.mock.calls[0]?.[0]).not.toHaveProperty("sessionKey");
});
it("keeps wake unscoped for non-agent session keys", () => {
emitExecSystemEvent("Exec finished", {
sessionKey: "global",
contextKey: "exec:run-global",
});
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
sessionKey: "global",
contextKey: "exec:run-global",
trusted: false,
});
const heartbeat = requireHeartbeatCall();
expect(heartbeat.coalesceMs).toBe(0);
expect(heartbeat.reason).toBe("exec-event");
});
it("ignores events without a session key", () => {
emitExecSystemEvent("Exec finished", {
sessionKey: " ",
contextKey: "exec:run-2",
});
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(requestHeartbeatMock).not.toHaveBeenCalled();
});
it("skips heartbeat wake for subagent session keys", () => {
emitExecSystemEvent("Exec finished", {
sessionKey: "agent:main:subagent:abc-123",
contextKey: "exec:run-sub",
});
expect(enqueueSystemEventMock).toHaveBeenCalledWith("Exec finished", {
sessionKey: "agent:main:subagent:abc-123",
contextKey: "exec:run-sub",
deliveryContext: undefined,
trusted: false,
});
expect(requestHeartbeatMock).not.toHaveBeenCalled();
});
});
describe("formatExecFailureReason", () => {
it("formats timeout guidance with the configured timeout", () => {
expect(
formatExecFailureReason({
failureKind: "overall-timeout",
exitSignal: "SIGKILL",
timeoutSec: 45,
}),
).toContain("45 seconds");
});
it("points long-running work to registered exec backgrounding", () => {
const reason = formatExecFailureReason({
failureKind: "overall-timeout",
exitSignal: "SIGKILL",
timeoutSec: 45,
});
expect(reason).toContain("background=true");
expect(reason).toContain("yieldMs");
expect(reason).toContain("Do not rely on shell backgrounding");
});
it("formats shell failures without timeout-specific guidance", () => {
expect(
formatExecFailureReason({
failureKind: "shell-command-not-found",
exitSignal: null,
timeoutSec: 45,
}),
).toBe("Command not found");
});
});
describe("buildExecExitOutcome", () => {
it("keeps non-zero normal exits in the completed path", () => {
const outcome = buildExecExitOutcome({
exit: {
reason: "exit",
exitCode: 1,
exitSignal: null,
durationMs: 123,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
},
aggregated: "done",
durationMs: 123,
timeoutSec: 30,
});
expect(outcome.status).toBe("completed");
if (outcome.status !== "completed") {
throw new Error(`Expected completed outcome, got ${outcome.status}`);
}
expect(outcome.exitCode).toBe(1);
expect(outcome.aggregated).toBe("done\n\n(Command exited with code 1)");
});
it("classifies timed out exits as failures with a reason", () => {
const outcome = buildExecExitOutcome({
exit: {
reason: "overall-timeout",
exitCode: null,
exitSignal: "SIGKILL",
durationMs: 123,
stdout: "",
stderr: "",
timedOut: true,
noOutputTimedOut: false,
},
aggregated: "",
durationMs: 123,
timeoutSec: 30,
});
expect(outcome.status).toBe("failed");
if (outcome.status !== "failed") {
throw new Error(`Expected timeout to fail, got ${outcome.status}`);
}
expect(outcome.failureKind).toBe("overall-timeout");
expect(outcome.timedOut).toBe(true);
expect(outcome.reason).toContain("30 seconds");
});
it("keeps timed out shell-backgrounded commands on the failed path", () => {
const outcome = buildExecExitOutcome({
exit: {
reason: "overall-timeout",
exitCode: null,
exitSignal: "SIGKILL",
durationMs: 123,
stdout: "",
stderr: "",
timedOut: true,
noOutputTimedOut: false,
},
aggregated: "started worker",
durationMs: 123,
timeoutSec: 30,
});
if (outcome.status !== "failed") {
throw new Error(`Expected timeout to fail, got ${outcome.status}`);
}
expect(outcome.failureKind).toBe("overall-timeout");
expect(outcome.timedOut).toBe(true);
expect(outcome.reason).toContain("background=true");
expect(outcome.reason).toContain("Do not rely on shell backgrounding");
});
});