mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-26 02:05:13 +00:00
* 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>
687 lines
21 KiB
TypeScript
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");
|
|
});
|
|
});
|