Skip to content

Commit 980a643

Browse files
committed
fix: keep claude live tools fresh for watchdog
1 parent 00fb152 commit 980a643

2 files changed

Lines changed: 413 additions & 1 deletion

File tree

src/agents/cli-runner.spawn.test.ts

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ import {
88
replyRunRegistry,
99
} from "../auto-reply/reply/reply-run-registry.js";
1010
import { onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js";
11+
import {
12+
onInternalDiagnosticEvent,
13+
waitForDiagnosticEventsDrained,
14+
} from "../infra/diagnostic-events.js";
15+
import {
16+
getDiagnosticSessionActivitySnapshot,
17+
resetDiagnosticRunActivityForTest,
18+
} from "../logging/diagnostic-run-activity.js";
1119
import type { getProcessSupervisor } from "../process/supervisor/index.js";
1220
import {
1321
makeBootstrapWarn as realMakeBootstrapWarn,
@@ -45,6 +53,7 @@ type SupervisorSpawnFn = ProcessSupervisor["spawn"];
4553

4654
beforeEach(() => {
4755
resetAgentEventsForTest();
56+
resetDiagnosticRunActivityForTest();
4857
resetClaudeLiveSessionsForTest();
4958
replyRunTesting.resetReplyRunRegistry();
5059
restoreCliRunnerPrepareTestDeps();
@@ -53,6 +62,8 @@ beforeEach(() => {
5362

5463
afterEach(() => {
5564
vi.restoreAllMocks();
65+
vi.useRealTimers();
66+
resetDiagnosticRunActivityForTest();
5667
resetClaudeLiveSessionsForTest();
5768
replyRunTesting.resetReplyRunRegistry();
5869
});
@@ -73,6 +84,7 @@ function buildPreparedCliRunContext(params: {
7384
skillsSnapshot?: PreparedCliRunContext["params"]["skillsSnapshot"];
7485
thinkLevel?: PreparedCliRunContext["params"]["thinkLevel"];
7586
workspaceDir?: string;
87+
timeoutMs?: number;
7688
}): PreparedCliRunContext {
7789
const workspaceDir = params.workspaceDir ?? "/tmp";
7890
const baseBackend =
@@ -116,7 +128,7 @@ function buildPreparedCliRunContext(params: {
116128
provider: params.provider,
117129
model: params.model,
118130
thinkLevel: params.thinkLevel,
119-
timeoutMs: 1_000,
131+
timeoutMs: params.timeoutMs ?? 1_000,
120132
runId: params.runId,
121133
skillsSnapshot: params.skillsSnapshot,
122134
},
@@ -1604,6 +1616,164 @@ ${JSON.stringify({
16041616
expect(parsed.response.response.toolUseID).toBe("tool-allow-1");
16051617
});
16061618

1619+
it("reports Claude live stream progress and keeps native tools fresh while they are running", async () => {
1620+
vi.useFakeTimers({
1621+
toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"],
1622+
});
1623+
vi.setSystemTime(new Date("2026-05-28T00:00:00.000Z"));
1624+
const diagnosticEvents: string[] = [];
1625+
const stopDiagnostics = onInternalDiagnosticEvent((event) => {
1626+
if (event.type === "run.progress" || event.type.startsWith("tool.execution.")) {
1627+
diagnosticEvents.push(event.type);
1628+
}
1629+
});
1630+
let stdoutListener: ((chunk: string) => void) | undefined;
1631+
const stdin = {
1632+
write: vi.fn((data: string, cb?: (err?: Error | null) => void) => {
1633+
stdoutListener?.(
1634+
[
1635+
JSON.stringify({
1636+
type: "system",
1637+
subtype: "init",
1638+
session_id: "live-diagnostics",
1639+
}),
1640+
JSON.stringify({
1641+
type: "assistant",
1642+
session_id: "live-diagnostics",
1643+
message: {
1644+
role: "assistant",
1645+
content: [
1646+
{
1647+
type: "tool_use",
1648+
id: "tool-live-1",
1649+
name: "Bash",
1650+
input: { command: "sleep 45" },
1651+
},
1652+
],
1653+
},
1654+
}),
1655+
].join("\n") + "\n",
1656+
);
1657+
cb?.();
1658+
}),
1659+
end: vi.fn(),
1660+
};
1661+
supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => {
1662+
const input = (args[0] ?? {}) as { onStdout?: (chunk: string) => void };
1663+
stdoutListener = input.onStdout;
1664+
return {
1665+
runId: "live-run-diagnostics",
1666+
pid: 3060,
1667+
startedAtMs: Date.now(),
1668+
stdin,
1669+
wait: vi.fn(() => new Promise(() => {})),
1670+
cancel: vi.fn(),
1671+
};
1672+
});
1673+
1674+
try {
1675+
const context = buildPreparedCliRunContext({
1676+
provider: "claude-cli",
1677+
model: "sonnet",
1678+
runId: "run-live-diagnostics",
1679+
sessionId: "session-live-diagnostics",
1680+
sessionKey: "agent:main:diagnostics",
1681+
prompt: "hello",
1682+
backend: { liveSession: "claude-stdio" },
1683+
timeoutMs: 120_000,
1684+
});
1685+
const resultPromise = runClaudeLiveSessionTurn({
1686+
context,
1687+
args: context.preparedBackend.backend.args ?? [],
1688+
env: {},
1689+
prompt: "hello",
1690+
useResume: false,
1691+
noOutputTimeoutMs: 120_000,
1692+
getProcessSupervisor: () => ({
1693+
spawn: (params: Parameters<SupervisorSpawnFn>[0]) =>
1694+
supervisorSpawnMock(params) as ReturnType<SupervisorSpawnFn>,
1695+
cancel: vi.fn(),
1696+
cancelScope: vi.fn(),
1697+
reconcileOrphans: vi.fn(),
1698+
getRecord: vi.fn(),
1699+
}),
1700+
onAssistantDelta: () => {},
1701+
cleanup: async () => {},
1702+
});
1703+
1704+
await waitForDiagnosticEventsDrained();
1705+
await vi.waitFor(() =>
1706+
expect(
1707+
getDiagnosticSessionActivitySnapshot({
1708+
sessionKey: "agent:main:diagnostics",
1709+
}).activeToolName,
1710+
).toBe("Bash"),
1711+
);
1712+
expect(
1713+
getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" })
1714+
.lastProgressReason,
1715+
).toBe("cli_live:tool_started");
1716+
1717+
await vi.advanceTimersByTimeAsync(10_000);
1718+
await waitForDiagnosticEventsDrained();
1719+
expect(
1720+
getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" })
1721+
.lastProgressReason,
1722+
).toBe("cli_live:tool_running");
1723+
expect(
1724+
getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" })
1725+
.lastProgressAgeMs,
1726+
).toBeLessThan(100);
1727+
1728+
stdoutListener?.(
1729+
[
1730+
JSON.stringify({
1731+
type: "user",
1732+
session_id: "live-diagnostics",
1733+
message: {
1734+
role: "user",
1735+
content: [
1736+
{
1737+
type: "tool_result",
1738+
tool_use_id: "tool-live-1",
1739+
content: "done",
1740+
},
1741+
],
1742+
},
1743+
}),
1744+
JSON.stringify({
1745+
type: "assistant",
1746+
session_id: "live-diagnostics",
1747+
message: {
1748+
role: "assistant",
1749+
content: [{ type: "text", text: "ok" }],
1750+
},
1751+
}),
1752+
JSON.stringify({
1753+
type: "result",
1754+
session_id: "live-diagnostics",
1755+
result: "ok",
1756+
}),
1757+
].join("\n") + "\n",
1758+
);
1759+
1760+
await expect(resultPromise).resolves.toMatchObject({ output: { text: "ok" } });
1761+
await waitForDiagnosticEventsDrained();
1762+
expect(
1763+
getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" })
1764+
.activeToolName,
1765+
).toBeUndefined();
1766+
expect(
1767+
getDiagnosticSessionActivitySnapshot({ sessionKey: "agent:main:diagnostics" })
1768+
.lastProgressReason,
1769+
).toBe("cli_live:result");
1770+
expect(diagnosticEvents).toContain("tool.execution.started");
1771+
expect(diagnosticEvents).toContain("tool.execution.completed");
1772+
} finally {
1773+
stopDiagnostics();
1774+
}
1775+
});
1776+
16071777
it("answers Claude live control_request can_use_tool with deny when exec policy is restrictive", async () => {
16081778
let stdoutListener: ((chunk: string) => void) | undefined;
16091779
const writes: string[] = [];

0 commit comments

Comments
 (0)