@tyvm/knowhow 0.0.138 → 0.0.141
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/autodoc/chat-guide.md +1 -0
- package/package.json +5 -2
- package/scripts/build-for-node.sh +4 -4
- package/src/agents/base/base.ts +254 -29
- package/src/agents/patcher/patcher.ts +1 -1
- package/src/agents/researcher/researcher.ts +2 -2
- package/src/agents/tools/agentCall.ts +6 -0
- package/src/agents/tools/connectAgent.ts +353 -0
- package/src/agents/tools/index.ts +6 -0
- package/src/agents/tools/list.ts +48 -0
- package/src/agents/tools/observe.ts +245 -0
- package/src/agents/tools/replyToParent.ts +122 -0
- package/src/agents/tools/runGenerate.ts +209 -0
- package/src/agents/tools/sendAgentMessage.ts +138 -0
- package/src/agents/tools/startAgentTask.ts +92 -5
- package/src/agents/tools/waitForAgentCompleted.ts +269 -0
- package/src/ai.ts +1 -0
- package/src/chat/modules/AgentModule.ts +89 -27
- package/src/chat/modules/SessionsModule.ts +6 -10
- package/src/clients/contextLimits.ts +26 -0
- package/src/clients/pricing/anthropic.ts +12 -0
- package/src/commands/agent.ts +22 -0
- package/src/commands/agents.ts +83 -0
- package/src/commands/misc.ts +6 -0
- package/src/commands/modules.ts +159 -7
- package/src/commands/services.ts +14 -0
- package/src/generate.ts +25 -3
- package/src/services/AgentService.ts +21 -3
- package/src/services/AgentSyncFs.ts +56 -0
- package/src/services/AgentSyncKnowhowWeb.ts +1 -1
- package/src/services/EventService.ts +4 -1
- package/src/services/KnowhowClient.ts +7 -0
- package/src/services/LazyToolsService.ts +7 -3
- package/src/services/MinimalToolsService.ts +15 -7
- package/src/services/SyncedAgentWatcher.ts +2 -0
- package/src/services/SyncerService.ts +35 -6
- package/src/services/Tools.ts +43 -3
- package/src/services/modules/index.ts +127 -46
- package/src/types.ts +5 -0
- package/tests/agents/interrupt.test.ts +11 -18
- package/tests/agents/observe-base.test.ts +183 -0
- package/tests/agents/tools/connectAgent.test.ts +138 -0
- package/tests/agents/tools/observe.test.ts +179 -0
- package/tests/agents/tools/replyToParent.test.ts +125 -0
- package/tests/agents/tools/sendAgentMessage.test.ts +99 -0
- package/tests/agents/tools/waitForAgentCompleted.test.ts +147 -0
- package/tests/chat/detach-attach-rendering.test.ts +315 -0
- package/tests/clients/contextLimits.test.ts +23 -1
- package/tests/clients/pricing.test.ts +20 -1
- package/tests/services/Tools.test.ts +172 -9
- package/tests/unit/modules/moduleLoading.test.ts +69 -0
- package/ts_build/package.json +5 -2
- package/ts_build/src/agents/base/base.d.ts +24 -1
- package/ts_build/src/agents/base/base.js +111 -16
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/agents/patcher/patcher.js +1 -1
- package/ts_build/src/agents/patcher/patcher.js.map +1 -1
- package/ts_build/src/agents/researcher/researcher.js +2 -2
- package/ts_build/src/agents/researcher/researcher.js.map +1 -1
- package/ts_build/src/agents/tools/agentCall.js.map +1 -1
- package/ts_build/src/agents/tools/connectAgent.d.ts +21 -0
- package/ts_build/src/agents/tools/connectAgent.js +284 -0
- package/ts_build/src/agents/tools/connectAgent.js.map +1 -0
- package/ts_build/src/agents/tools/index.d.ts +6 -0
- package/ts_build/src/agents/tools/index.js +6 -0
- package/ts_build/src/agents/tools/index.js.map +1 -1
- package/ts_build/src/agents/tools/list.js +40 -0
- package/ts_build/src/agents/tools/list.js.map +1 -1
- package/ts_build/src/agents/tools/observe.d.ts +20 -0
- package/ts_build/src/agents/tools/observe.js +144 -0
- package/ts_build/src/agents/tools/observe.js.map +1 -0
- package/ts_build/src/agents/tools/replyToParent.d.ts +4 -0
- package/ts_build/src/agents/tools/replyToParent.js +118 -0
- package/ts_build/src/agents/tools/replyToParent.js.map +1 -0
- package/ts_build/src/agents/tools/runGenerate.d.ts +19 -0
- package/ts_build/src/agents/tools/runGenerate.js +138 -0
- package/ts_build/src/agents/tools/runGenerate.js.map +1 -0
- package/ts_build/src/agents/tools/sendAgentMessage.d.ts +11 -0
- package/ts_build/src/agents/tools/sendAgentMessage.js +118 -0
- package/ts_build/src/agents/tools/sendAgentMessage.js.map +1 -0
- package/ts_build/src/agents/tools/startAgentTask.d.ts +7 -0
- package/ts_build/src/agents/tools/startAgentTask.js +53 -6
- package/ts_build/src/agents/tools/startAgentTask.js.map +1 -1
- package/ts_build/src/agents/tools/waitForAgentCompleted.d.ts +10 -0
- package/ts_build/src/agents/tools/waitForAgentCompleted.js +229 -0
- package/ts_build/src/agents/tools/waitForAgentCompleted.js.map +1 -0
- package/ts_build/src/ai.d.ts +1 -0
- package/ts_build/src/ai.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.d.ts +3 -1
- package/ts_build/src/chat/modules/AgentModule.js +46 -21
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/chat/modules/SessionsModule.js +1 -6
- package/ts_build/src/chat/modules/SessionsModule.js.map +1 -1
- package/ts_build/src/clients/contextLimits.js +21 -0
- package/ts_build/src/clients/contextLimits.js.map +1 -1
- package/ts_build/src/clients/pricing/anthropic.d.ts +2 -0
- package/ts_build/src/clients/pricing/anthropic.js +9 -0
- package/ts_build/src/clients/pricing/anthropic.js.map +1 -1
- package/ts_build/src/commands/agent.js +11 -0
- package/ts_build/src/commands/agent.js.map +1 -1
- package/ts_build/src/commands/agents.js +61 -0
- package/ts_build/src/commands/agents.js.map +1 -1
- package/ts_build/src/commands/misc.js +2 -0
- package/ts_build/src/commands/misc.js.map +1 -1
- package/ts_build/src/commands/modules.js +86 -7
- package/ts_build/src/commands/modules.js.map +1 -1
- package/ts_build/src/commands/services.js +10 -0
- package/ts_build/src/commands/services.js.map +1 -1
- package/ts_build/src/generate.d.ts +2 -0
- package/ts_build/src/generate.js +11 -3
- package/ts_build/src/generate.js.map +1 -1
- package/ts_build/src/services/AgentService.d.ts +4 -1
- package/ts_build/src/services/AgentService.js +9 -2
- package/ts_build/src/services/AgentService.js.map +1 -1
- package/ts_build/src/services/AgentSyncFs.d.ts +2 -0
- package/ts_build/src/services/AgentSyncFs.js +35 -0
- package/ts_build/src/services/AgentSyncFs.js.map +1 -1
- package/ts_build/src/services/AgentSyncKnowhowWeb.d.ts +1 -1
- package/ts_build/src/services/EventService.d.ts +4 -1
- package/ts_build/src/services/EventService.js.map +1 -1
- package/ts_build/src/services/KnowhowClient.js +3 -0
- package/ts_build/src/services/KnowhowClient.js.map +1 -1
- package/ts_build/src/services/LazyToolsService.d.ts +2 -2
- package/ts_build/src/services/LazyToolsService.js +2 -2
- package/ts_build/src/services/LazyToolsService.js.map +1 -1
- package/ts_build/src/services/MinimalToolsService.d.ts +2 -2
- package/ts_build/src/services/MinimalToolsService.js +6 -6
- package/ts_build/src/services/MinimalToolsService.js.map +1 -1
- package/ts_build/src/services/SyncedAgentWatcher.d.ts +1 -0
- package/ts_build/src/services/SyncedAgentWatcher.js.map +1 -1
- package/ts_build/src/services/SyncerService.d.ts +2 -1
- package/ts_build/src/services/SyncerService.js +8 -2
- package/ts_build/src/services/SyncerService.js.map +1 -1
- package/ts_build/src/services/Tools.d.ts +6 -1
- package/ts_build/src/services/Tools.js +9 -3
- package/ts_build/src/services/Tools.js.map +1 -1
- package/ts_build/src/services/modules/index.d.ts +1 -1
- package/ts_build/src/services/modules/index.js +67 -27
- package/ts_build/src/services/modules/index.js.map +1 -1
- package/ts_build/src/types.d.ts +3 -0
- package/ts_build/src/types.js.map +1 -1
- package/ts_build/tests/agents/interrupt.test.d.ts +0 -1
- package/ts_build/tests/agents/interrupt.test.js +3 -13
- package/ts_build/tests/agents/interrupt.test.js.map +1 -1
- package/ts_build/tests/agents/observe-base.test.d.ts +1 -0
- package/ts_build/tests/agents/observe-base.test.js +149 -0
- package/ts_build/tests/agents/observe-base.test.js.map +1 -0
- package/ts_build/tests/agents/tools/connectAgent.test.d.ts +1 -0
- package/ts_build/tests/agents/tools/connectAgent.test.js +142 -0
- package/ts_build/tests/agents/tools/connectAgent.test.js.map +1 -0
- package/ts_build/tests/agents/tools/observe.test.d.ts +1 -0
- package/ts_build/tests/agents/tools/observe.test.js +148 -0
- package/ts_build/tests/agents/tools/observe.test.js.map +1 -0
- package/ts_build/tests/agents/tools/replyToParent.test.d.ts +1 -0
- package/ts_build/tests/agents/tools/replyToParent.test.js +116 -0
- package/ts_build/tests/agents/tools/replyToParent.test.js.map +1 -0
- package/ts_build/tests/agents/tools/sendAgentMessage.test.d.ts +1 -0
- package/ts_build/tests/agents/tools/sendAgentMessage.test.js +112 -0
- package/ts_build/tests/agents/tools/sendAgentMessage.test.js.map +1 -0
- package/ts_build/tests/agents/tools/waitForAgentCompleted.test.d.ts +1 -0
- package/ts_build/tests/agents/tools/waitForAgentCompleted.test.js +147 -0
- package/ts_build/tests/agents/tools/waitForAgentCompleted.test.js.map +1 -0
- package/ts_build/tests/chat/detach-attach-rendering.test.d.ts +1 -0
- package/ts_build/tests/chat/detach-attach-rendering.test.js +168 -0
- package/ts_build/tests/chat/detach-attach-rendering.test.js.map +1 -0
- package/ts_build/tests/clients/contextLimits.test.js +11 -1
- package/ts_build/tests/clients/contextLimits.test.js.map +1 -1
- package/ts_build/tests/clients/pricing.test.js +10 -1
- package/ts_build/tests/clients/pricing.test.js.map +1 -1
- package/ts_build/tests/services/Tools.test.js +126 -8
- package/ts_build/tests/services/Tools.test.js.map +1 -1
- package/ts_build/tests/unit/modules/moduleLoading.test.js +84 -0
- package/ts_build/tests/unit/modules/moduleLoading.test.js.map +1 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { sendAgentMessage } from "../../../src/agents/tools/sendAgentMessage";
|
|
5
|
+
|
|
6
|
+
describe("sendAgentMessage", () => {
|
|
7
|
+
let tmpCwd: string;
|
|
8
|
+
let originalCwd: string;
|
|
9
|
+
let agentsDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
originalCwd = process.cwd();
|
|
13
|
+
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), "send-msg-"));
|
|
14
|
+
process.chdir(tmpCwd);
|
|
15
|
+
agentsDir = path.join(tmpCwd, ".knowhow", "processes", "agents");
|
|
16
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
process.chdir(originalCwd);
|
|
21
|
+
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function makeTask(taskId: string): string {
|
|
25
|
+
const dir = path.join(agentsDir, taskId);
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it("requires taskId and message", async () => {
|
|
31
|
+
expect(await sendAgentMessage({ taskId: "", message: "hi" })).toMatch(
|
|
32
|
+
/taskId is required/
|
|
33
|
+
);
|
|
34
|
+
expect(await sendAgentMessage({ taskId: "x", message: "" })).toMatch(
|
|
35
|
+
/message is required/
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("errors when the target task dir doesn't exist", async () => {
|
|
40
|
+
const res = await sendAgentMessage({ taskId: "nope", message: "hi" });
|
|
41
|
+
expect(res).toMatch(/target task directory not found/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("appends an envelope with sender identity from _ctx", async () => {
|
|
45
|
+
makeTask("child-1");
|
|
46
|
+
const res = await sendAgentMessage({
|
|
47
|
+
taskId: "child-1",
|
|
48
|
+
message: "do the thing",
|
|
49
|
+
_ctx: { taskId: "parent-1", caller: { name: "Developer" } },
|
|
50
|
+
});
|
|
51
|
+
expect(res).toMatch(/Sent message to task child-1/);
|
|
52
|
+
const input = fs.readFileSync(
|
|
53
|
+
path.join(agentsDir, "child-1", "input.txt"),
|
|
54
|
+
"utf8"
|
|
55
|
+
);
|
|
56
|
+
expect(input).toContain("[from:parent-1/Developer] do the thing");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("uses explicit from when provided", async () => {
|
|
60
|
+
makeTask("child-2");
|
|
61
|
+
await sendAgentMessage({
|
|
62
|
+
taskId: "child-2",
|
|
63
|
+
message: "msg",
|
|
64
|
+
from: "orchestrator",
|
|
65
|
+
});
|
|
66
|
+
const input = fs.readFileSync(
|
|
67
|
+
path.join(agentsDir, "child-2", "input.txt"),
|
|
68
|
+
"utf8"
|
|
69
|
+
);
|
|
70
|
+
expect(input).toContain("[from:orchestrator] msg");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("delivers as /poke when poke:true (interrupts recipient)", async () => {
|
|
74
|
+
makeTask("child-3");
|
|
75
|
+
await sendAgentMessage({
|
|
76
|
+
taskId: "child-3",
|
|
77
|
+
message: "stop and reconsider",
|
|
78
|
+
from: "boss",
|
|
79
|
+
poke: true,
|
|
80
|
+
});
|
|
81
|
+
const input = fs.readFileSync(
|
|
82
|
+
path.join(agentsDir, "child-3", "input.txt"),
|
|
83
|
+
"utf8"
|
|
84
|
+
);
|
|
85
|
+
expect(input).toMatch(/^\/poke \[from:boss\] stop and reconsider/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("appends without clobbering existing pending messages", async () => {
|
|
89
|
+
const dir = makeTask("child-4");
|
|
90
|
+
fs.writeFileSync(path.join(dir, "input.txt"), "existing line\n", "utf8");
|
|
91
|
+
await sendAgentMessage({
|
|
92
|
+
taskId: "child-4",
|
|
93
|
+
message: "second",
|
|
94
|
+
from: "a",
|
|
95
|
+
});
|
|
96
|
+
const input = fs.readFileSync(path.join(dir, "input.txt"), "utf8");
|
|
97
|
+
expect(input).toBe("existing line\n[from:a] second\n");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { waitForAgentCompleted } from "../../../src/agents/tools/waitForAgentCompleted";
|
|
5
|
+
|
|
6
|
+
describe("waitForAgentCompleted", () => {
|
|
7
|
+
let tmpCwd: string;
|
|
8
|
+
let originalCwd: string;
|
|
9
|
+
let agentsDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
originalCwd = process.cwd();
|
|
13
|
+
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), "wait-agent-"));
|
|
14
|
+
process.chdir(tmpCwd);
|
|
15
|
+
agentsDir = path.join(tmpCwd, ".knowhow", "processes", "agents");
|
|
16
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
process.chdir(originalCwd);
|
|
21
|
+
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function makeTask(taskId: string, meta: Record<string, any> = {}, status?: string) {
|
|
25
|
+
const dir = path.join(agentsDir, taskId);
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
fs.writeFileSync(
|
|
28
|
+
path.join(dir, "metadata.json"),
|
|
29
|
+
JSON.stringify({ taskId, ...meta }, null, 2),
|
|
30
|
+
"utf8"
|
|
31
|
+
);
|
|
32
|
+
if (status !== undefined) {
|
|
33
|
+
fs.writeFileSync(path.join(dir, "status.txt"), status, "utf8");
|
|
34
|
+
}
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it("returns error when taskId missing", async () => {
|
|
39
|
+
const res = JSON.parse(await waitForAgentCompleted({ taskId: "" }));
|
|
40
|
+
expect(res.status).toBe("error");
|
|
41
|
+
expect(res.error).toMatch(/taskId is required/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns error when task directory does not exist", async () => {
|
|
45
|
+
const res = JSON.parse(await waitForAgentCompleted({ taskId: "nope" }));
|
|
46
|
+
expect(res.status).toBe("error");
|
|
47
|
+
expect(res.error).toMatch(/No agent task directory/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns immediately with structured result for a completed task", async () => {
|
|
51
|
+
makeTask("done-1", {
|
|
52
|
+
status: "completed",
|
|
53
|
+
totalCostUsd: 1.23,
|
|
54
|
+
result: "the final answer",
|
|
55
|
+
artifacts: ["a.txt"],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const res = JSON.parse(await waitForAgentCompleted({ taskId: "done-1" }));
|
|
59
|
+
expect(res.status).toBe("completed");
|
|
60
|
+
expect(res.costUsd).toBe(1.23);
|
|
61
|
+
expect(res.finalAnswer).toBe("the final answer");
|
|
62
|
+
expect(res.artifacts).toEqual(["a.txt"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("falls back to last assistant message when no result field", async () => {
|
|
66
|
+
makeTask("done-2", {
|
|
67
|
+
status: "completed",
|
|
68
|
+
totalCostUsd: 0.5,
|
|
69
|
+
threads: [
|
|
70
|
+
[
|
|
71
|
+
{ role: "user", content: "do it" },
|
|
72
|
+
{ role: "assistant", content: "I did it" },
|
|
73
|
+
],
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const res = JSON.parse(await waitForAgentCompleted({ taskId: "done-2" }));
|
|
78
|
+
expect(res.finalAnswer).toBe("I did it");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("treats inProgress:false with no status as completed", async () => {
|
|
82
|
+
makeTask("done-3", { inProgress: false, totalCostUsd: 0.1, result: "ok" });
|
|
83
|
+
const res = JSON.parse(await waitForAgentCompleted({ taskId: "done-3" }));
|
|
84
|
+
expect(res.status).toBe("completed");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("times out on a still-running task", async () => {
|
|
88
|
+
makeTask("running-1", { status: "running", totalCostUsd: 0.2 });
|
|
89
|
+
const res = JSON.parse(
|
|
90
|
+
await waitForAgentCompleted({
|
|
91
|
+
taskId: "running-1",
|
|
92
|
+
timeoutMs: 300,
|
|
93
|
+
pollIntervalMs: 100,
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
expect(res.timedOut).toBe(true);
|
|
97
|
+
expect(res.status).toBe("running");
|
|
98
|
+
expect(res.costUsd).toBe(0.2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("resolves when a running task transitions to completed mid-poll", async () => {
|
|
102
|
+
const dir = makeTask("running-2", { status: "running", totalCostUsd: 0 });
|
|
103
|
+
|
|
104
|
+
// Flip to completed shortly after we start waiting.
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
fs.writeFileSync(
|
|
107
|
+
path.join(dir, "metadata.json"),
|
|
108
|
+
JSON.stringify(
|
|
109
|
+
{
|
|
110
|
+
taskId: "running-2",
|
|
111
|
+
status: "completed",
|
|
112
|
+
totalCostUsd: 2.0,
|
|
113
|
+
result: "eventually done",
|
|
114
|
+
},
|
|
115
|
+
null,
|
|
116
|
+
2
|
|
117
|
+
),
|
|
118
|
+
"utf8"
|
|
119
|
+
);
|
|
120
|
+
}, 150);
|
|
121
|
+
|
|
122
|
+
const res = JSON.parse(
|
|
123
|
+
await waitForAgentCompleted({
|
|
124
|
+
taskId: "running-2",
|
|
125
|
+
timeoutMs: 3000,
|
|
126
|
+
pollIntervalMs: 100,
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
expect(res.status).toBe("completed");
|
|
130
|
+
expect(res.finalAnswer).toBe("eventually done");
|
|
131
|
+
expect(res.costUsd).toBe(2.0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("fails a task whose pid is dead without a terminal status", async () => {
|
|
135
|
+
// Use a pid that is virtually guaranteed not to exist.
|
|
136
|
+
makeTask("crashed-1", { status: "running", totalCostUsd: 0.3, pid: 999999 });
|
|
137
|
+
const res = JSON.parse(
|
|
138
|
+
await waitForAgentCompleted({
|
|
139
|
+
taskId: "crashed-1",
|
|
140
|
+
timeoutMs: 5000,
|
|
141
|
+
pollIntervalMs: 100,
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
expect(res.status).toBe("failed");
|
|
145
|
+
expect(res.error).toMatch(/exited without writing a terminal status/);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Tests for detach / re-attach rendering correctness.
|
|
4
|
+
*
|
|
5
|
+
* Bug: after calling /detach and then /attach again, the agent's tool calls
|
|
6
|
+
* and messages were not rendering to the console.
|
|
7
|
+
*
|
|
8
|
+
* Root cause: both SessionsModule.attachToFsAgent / attachToWebAgent AND
|
|
9
|
+
* AgentModule.attachedAgentChatLoop were calling wireAgentRendering().
|
|
10
|
+
* Since wireAgentRendering uses setListener (keyed, replaces-on-conflict),
|
|
11
|
+
* the second call on the SAME EventService replaced the first — but the
|
|
12
|
+
* watcher's `done` event handler registered by SessionsModule was also
|
|
13
|
+
* removed. More critically: after /detach, unwireAgentRendering() removes
|
|
14
|
+
* the keyed listeners from `_wireAgentEvents` and clears the stored ref.
|
|
15
|
+
* On re-attach a new FsSyncedAgentWatcher is created with a fresh
|
|
16
|
+
* EventService, but the double-wire means the stored `_wireAgentEvents`
|
|
17
|
+
* ref held by the AgentModule was pointing at the WRONG EventService
|
|
18
|
+
* (the one from the second wireAgentRendering call).
|
|
19
|
+
*
|
|
20
|
+
* The fix: SessionsModule no longer calls wireAgentRendering itself —
|
|
21
|
+
* it delegates entirely to attachedAgentChatLoop which already calls it.
|
|
22
|
+
*
|
|
23
|
+
* These tests verify the wireAgentRendering / unwireAgentRendering contract
|
|
24
|
+
* using real EventService instances so we catch regressions in EventService
|
|
25
|
+
* changes too.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { EventService } from "../../src/services/EventService";
|
|
29
|
+
|
|
30
|
+
// ─── Minimal harness that mirrors AgentModule's wire/unwire logic exactly ────
|
|
31
|
+
|
|
32
|
+
const EVENT_TYPES = {
|
|
33
|
+
done: "done",
|
|
34
|
+
toolCall: "tool:pre_call",
|
|
35
|
+
toolUsed: "tool:post_call",
|
|
36
|
+
agentSay: "agent:say",
|
|
37
|
+
threadUpdate: "thread_update",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
interface RenderEvent {
|
|
41
|
+
type: string;
|
|
42
|
+
taskId: string;
|
|
43
|
+
agentName: string;
|
|
44
|
+
data: any;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class RenderingHarness {
|
|
48
|
+
// Mirror of AgentModule's stored wire params
|
|
49
|
+
private _wireAgentEvents: EventService | undefined;
|
|
50
|
+
private _wireTaskId: string | undefined;
|
|
51
|
+
private _wireAgentName: string | undefined;
|
|
52
|
+
private _wireEventTypes:
|
|
53
|
+
| { toolCall?: string; toolUsed?: string; agentSay?: string; done: string }
|
|
54
|
+
| undefined;
|
|
55
|
+
|
|
56
|
+
public rendered: RenderEvent[] = [];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Mirrors AgentModule.wireAgentRendering exactly (uses real EventService.setListener).
|
|
60
|
+
*/
|
|
61
|
+
wireAgentRendering(
|
|
62
|
+
taskId: string,
|
|
63
|
+
agentEvents: EventService,
|
|
64
|
+
eventTypes: { toolCall?: string; toolUsed?: string; agentSay?: string; done: string },
|
|
65
|
+
agentName: string
|
|
66
|
+
): void {
|
|
67
|
+
if (eventTypes.toolCall) {
|
|
68
|
+
agentEvents.setListener(
|
|
69
|
+
{ key: "agentModule:render:toolCall", event: eventTypes.toolCall },
|
|
70
|
+
(data: any) => this.rendered.push({ type: "toolCall", taskId, agentName, data })
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (eventTypes.toolUsed) {
|
|
74
|
+
agentEvents.setListener(
|
|
75
|
+
{ key: "agentModule:render:toolUsed", event: eventTypes.toolUsed },
|
|
76
|
+
(data: any) => this.rendered.push({ type: "toolResult", taskId, agentName, data })
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (eventTypes.agentSay) {
|
|
80
|
+
agentEvents.setListener(
|
|
81
|
+
{ key: "agentModule:render:agentSay", event: eventTypes.agentSay },
|
|
82
|
+
(data: any) => this.rendered.push({ type: "agentMessage", taskId, agentName, data })
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
this._wireAgentEvents = agentEvents;
|
|
86
|
+
this._wireTaskId = taskId;
|
|
87
|
+
this._wireAgentName = agentName;
|
|
88
|
+
this._wireEventTypes = eventTypes;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Mirrors AgentModule.unwireAgentRendering exactly.
|
|
93
|
+
*/
|
|
94
|
+
unwireAgentRendering(): void {
|
|
95
|
+
if (this._wireAgentEvents) {
|
|
96
|
+
this._wireAgentEvents.removeManagedListenersByPrefix("agentModule:render:");
|
|
97
|
+
this._wireAgentEvents = undefined;
|
|
98
|
+
}
|
|
99
|
+
this._wireTaskId = undefined;
|
|
100
|
+
this._wireAgentName = undefined;
|
|
101
|
+
this._wireEventTypes = undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
reset() {
|
|
105
|
+
this.rendered = [];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
describe("detach / re-attach rendering", () => {
|
|
112
|
+
let harness: RenderingHarness;
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
harness = new RenderingHarness();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// 1. Basic: emitted events reach the renderer after wireAgentRendering ────────
|
|
119
|
+
|
|
120
|
+
it("emitted events reach the renderer after wireAgentRendering", () => {
|
|
121
|
+
const events = new EventService();
|
|
122
|
+
harness.wireAgentRendering("task-1", events, EVENT_TYPES, "Dev");
|
|
123
|
+
|
|
124
|
+
events.emit(EVENT_TYPES.toolCall, { toolCall: { function: { name: "execCommand" } } });
|
|
125
|
+
events.emit(EVENT_TYPES.agentSay, { message: "hello" });
|
|
126
|
+
events.emit(EVENT_TYPES.toolUsed, { toolCall: {}, functionResp: "output" });
|
|
127
|
+
|
|
128
|
+
expect(harness.rendered).toHaveLength(3);
|
|
129
|
+
expect(harness.rendered[0].type).toBe("toolCall");
|
|
130
|
+
expect(harness.rendered[1].type).toBe("agentMessage");
|
|
131
|
+
expect(harness.rendered[2].type).toBe("toolResult");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// 2. After detach (unwire), old EventService no longer triggers renders ───────
|
|
135
|
+
|
|
136
|
+
it("old EventService does NOT render after unwireAgentRendering (detach)", () => {
|
|
137
|
+
const events = new EventService();
|
|
138
|
+
harness.wireAgentRendering("task-1", events, EVENT_TYPES, "Dev");
|
|
139
|
+
|
|
140
|
+
// Verify wired first
|
|
141
|
+
events.emit(EVENT_TYPES.toolCall, { toolCall: {} });
|
|
142
|
+
expect(harness.rendered).toHaveLength(1);
|
|
143
|
+
harness.reset();
|
|
144
|
+
|
|
145
|
+
// Detach
|
|
146
|
+
harness.unwireAgentRendering();
|
|
147
|
+
|
|
148
|
+
// Emit on the OLD EventService — nothing should render
|
|
149
|
+
events.emit(EVENT_TYPES.toolCall, { toolCall: {} });
|
|
150
|
+
events.emit(EVENT_TYPES.agentSay, { message: "zombie" });
|
|
151
|
+
events.emit(EVENT_TYPES.toolUsed, { toolCall: {}, functionResp: "output" });
|
|
152
|
+
|
|
153
|
+
expect(harness.rendered).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// 3. Re-attach on a NEW EventService renders correctly ───────────────────────
|
|
157
|
+
|
|
158
|
+
it("events on a NEW EventService render after re-wiring (re-attach)", () => {
|
|
159
|
+
const oldEvents = new EventService();
|
|
160
|
+
harness.wireAgentRendering("task-1", oldEvents, EVENT_TYPES, "Dev");
|
|
161
|
+
harness.unwireAgentRendering();
|
|
162
|
+
|
|
163
|
+
// Re-attach: new watcher = new EventService
|
|
164
|
+
const newEvents = new EventService();
|
|
165
|
+
harness.wireAgentRendering("task-1", newEvents, EVENT_TYPES, "Dev");
|
|
166
|
+
|
|
167
|
+
newEvents.emit(EVENT_TYPES.toolCall, { toolCall: { function: { name: "readFile" } } });
|
|
168
|
+
newEvents.emit(EVENT_TYPES.agentSay, { message: "re-attached message" });
|
|
169
|
+
|
|
170
|
+
expect(harness.rendered).toHaveLength(2);
|
|
171
|
+
expect(harness.rendered[0].type).toBe("toolCall");
|
|
172
|
+
expect(harness.rendered[0].data.toolCall.function.name).toBe("readFile");
|
|
173
|
+
expect(harness.rendered[1].type).toBe("agentMessage");
|
|
174
|
+
expect(harness.rendered[1].data.message).toBe("re-attached message");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// 4. Old EventService still silent after re-attach on new EventService ────────
|
|
178
|
+
|
|
179
|
+
it("old EventService stays silent after re-attach on new EventService", () => {
|
|
180
|
+
const oldEvents = new EventService();
|
|
181
|
+
harness.wireAgentRendering("task-1", oldEvents, EVENT_TYPES, "Dev");
|
|
182
|
+
harness.unwireAgentRendering();
|
|
183
|
+
|
|
184
|
+
const newEvents = new EventService();
|
|
185
|
+
harness.wireAgentRendering("task-1", newEvents, EVENT_TYPES, "Dev");
|
|
186
|
+
|
|
187
|
+
// New events render
|
|
188
|
+
newEvents.emit(EVENT_TYPES.agentSay, { message: "new" });
|
|
189
|
+
expect(harness.rendered).toHaveLength(1);
|
|
190
|
+
|
|
191
|
+
// Old events still silent
|
|
192
|
+
oldEvents.emit(EVENT_TYPES.toolCall, { toolCall: {} });
|
|
193
|
+
oldEvents.emit(EVENT_TYPES.agentSay, { message: "old" });
|
|
194
|
+
expect(harness.rendered).toHaveLength(1); // unchanged
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// 5. Double-wire on the SAME EventService does NOT double-render ──────────────
|
|
198
|
+
//
|
|
199
|
+
// This is the bug that existed: SessionsModule.attachToFsAgent called
|
|
200
|
+
// wireAgentRendering, then attachedAgentChatLoop also called it.
|
|
201
|
+
// Since setListener is keyed (replaces), the net result is one listener —
|
|
202
|
+
// but the done handler registered by SessionsModule was lost.
|
|
203
|
+
// The fix removes the extra wireAgentRendering from SessionsModule.
|
|
204
|
+
|
|
205
|
+
it("calling wireAgentRendering twice on same EventService does NOT double-render", () => {
|
|
206
|
+
const events = new EventService();
|
|
207
|
+
|
|
208
|
+
// Simulate the OLD (buggy) behavior: SessionsModule wires, then attachedAgentChatLoop wires again
|
|
209
|
+
harness.wireAgentRendering("task-1", events, EVENT_TYPES, "Dev"); // first call
|
|
210
|
+
harness.wireAgentRendering("task-1", events, EVENT_TYPES, "Dev"); // second call (setListener replaces)
|
|
211
|
+
|
|
212
|
+
events.emit(EVENT_TYPES.toolCall, { toolCall: {} });
|
|
213
|
+
|
|
214
|
+
// setListener keyed — only ONE render expected, not two
|
|
215
|
+
expect(harness.rendered).toHaveLength(1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// 6. Full cycle: attach → emit → detach → re-attach → emit all render correctly
|
|
219
|
+
|
|
220
|
+
it("full attach → detach → re-attach cycle: all phases render correctly", () => {
|
|
221
|
+
// ── First attach ──────────────────────────────────────────────────────────
|
|
222
|
+
const watcher1 = new EventService();
|
|
223
|
+
harness.wireAgentRendering("task-A", watcher1, EVENT_TYPES, "Developer");
|
|
224
|
+
|
|
225
|
+
watcher1.emit(EVENT_TYPES.toolCall, { toolCall: { function: { name: "execCommand" } } });
|
|
226
|
+
watcher1.emit(EVENT_TYPES.agentSay, { message: "initial work" });
|
|
227
|
+
expect(harness.rendered).toHaveLength(2);
|
|
228
|
+
harness.reset();
|
|
229
|
+
|
|
230
|
+
// ── /detach ───────────────────────────────────────────────────────────────
|
|
231
|
+
harness.unwireAgentRendering();
|
|
232
|
+
|
|
233
|
+
// Events from watcher1 after detach should NOT render
|
|
234
|
+
watcher1.emit(EVENT_TYPES.toolCall, { toolCall: {} });
|
|
235
|
+
watcher1.emit(EVENT_TYPES.agentSay, { message: "zombie message" });
|
|
236
|
+
expect(harness.rendered).toHaveLength(0);
|
|
237
|
+
|
|
238
|
+
// ── Re-attach (new FsSyncedAgentWatcher creates a new EventService) ───────
|
|
239
|
+
const watcher2 = new EventService();
|
|
240
|
+
harness.wireAgentRendering("task-A", watcher2, EVENT_TYPES, "Developer");
|
|
241
|
+
|
|
242
|
+
watcher2.emit(EVENT_TYPES.toolCall, { toolCall: { function: { name: "readFile" } } });
|
|
243
|
+
watcher2.emit(EVENT_TYPES.agentSay, { message: "continuing after re-attach" });
|
|
244
|
+
|
|
245
|
+
// THE CRITICAL ASSERTION: renders must arrive after re-attach
|
|
246
|
+
expect(harness.rendered).toHaveLength(2);
|
|
247
|
+
expect(harness.rendered[0].type).toBe("toolCall");
|
|
248
|
+
expect(harness.rendered[0].data.toolCall.function.name).toBe("readFile");
|
|
249
|
+
expect(harness.rendered[1].type).toBe("agentMessage");
|
|
250
|
+
expect(harness.rendered[1].data.message).toBe("continuing after re-attach");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// 7. toolResult renders after re-attach ──────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
it("toolResult events render after re-attach", () => {
|
|
256
|
+
const events1 = new EventService();
|
|
257
|
+
harness.wireAgentRendering("task-1", events1, EVENT_TYPES, "Dev");
|
|
258
|
+
harness.unwireAgentRendering();
|
|
259
|
+
|
|
260
|
+
const events2 = new EventService();
|
|
261
|
+
harness.wireAgentRendering("task-1", events2, EVENT_TYPES, "Dev");
|
|
262
|
+
|
|
263
|
+
events2.emit(EVENT_TYPES.toolUsed, {
|
|
264
|
+
toolCall: { function: { name: "execCommand" } },
|
|
265
|
+
functionResp: "$ echo hello\nhello",
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(harness.rendered).toHaveLength(1);
|
|
269
|
+
expect(harness.rendered[0].type).toBe("toolResult");
|
|
270
|
+
expect(harness.rendered[0].data.functionResp).toBe("$ echo hello\nhello");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// 8. Multiple sequential attach/detach cycles all work ───────────────────────
|
|
274
|
+
|
|
275
|
+
it("3 sequential attach/detach cycles all render correctly", () => {
|
|
276
|
+
for (let i = 0; i < 3; i++) {
|
|
277
|
+
harness.reset();
|
|
278
|
+
const events = new EventService();
|
|
279
|
+
harness.wireAgentRendering(`task-${i}`, events, EVENT_TYPES, "Dev");
|
|
280
|
+
|
|
281
|
+
events.emit(EVENT_TYPES.agentSay, { message: `cycle ${i}` });
|
|
282
|
+
expect(harness.rendered).toHaveLength(1);
|
|
283
|
+
expect(harness.rendered[0].data.message).toBe(`cycle ${i}`);
|
|
284
|
+
expect(harness.rendered[0].taskId).toBe(`task-${i}`);
|
|
285
|
+
|
|
286
|
+
// Detach: old events stop rendering
|
|
287
|
+
harness.unwireAgentRendering();
|
|
288
|
+
events.emit(EVENT_TYPES.agentSay, { message: "should not render" });
|
|
289
|
+
expect(harness.rendered).toHaveLength(1); // still 1, not 2
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// 9. hasManagedListener confirms listener is present/absent at right times ───
|
|
294
|
+
|
|
295
|
+
it("hasManagedListener reflects wired/unwired state correctly", () => {
|
|
296
|
+
const events = new EventService();
|
|
297
|
+
|
|
298
|
+
// Before wiring: no managed render listeners
|
|
299
|
+
expect(events.hasManagedListener("agentModule:render:toolCall")).toBe(false);
|
|
300
|
+
expect(events.hasManagedListener("agentModule:render:agentSay")).toBe(false);
|
|
301
|
+
|
|
302
|
+
harness.wireAgentRendering("task-1", events, EVENT_TYPES, "Dev");
|
|
303
|
+
|
|
304
|
+
// After wiring: listeners registered
|
|
305
|
+
expect(events.hasManagedListener("agentModule:render:toolCall")).toBe(true);
|
|
306
|
+
expect(events.hasManagedListener("agentModule:render:toolUsed")).toBe(true);
|
|
307
|
+
expect(events.hasManagedListener("agentModule:render:agentSay")).toBe(true);
|
|
308
|
+
|
|
309
|
+
harness.unwireAgentRendering();
|
|
310
|
+
|
|
311
|
+
// After detach: listeners removed
|
|
312
|
+
expect(events.hasManagedListener("agentModule:render:toolCall")).toBe(false);
|
|
313
|
+
expect(events.hasManagedListener("agentModule:render:agentSay")).toBe(false);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -4,6 +4,27 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { Models } from "../../src/types";
|
|
6
6
|
import { ContextLimits, getModelContextLimit, DEFAULT_CONTEXT_LIMIT } from "../../src/clients/contextLimits";
|
|
7
|
+
import {
|
|
8
|
+
OpenAiTextPricing,
|
|
9
|
+
GeminiTextPricing,
|
|
10
|
+
AnthropicTextPricing,
|
|
11
|
+
XaiTextPricing,
|
|
12
|
+
} from "../../src/clients/pricing";
|
|
13
|
+
|
|
14
|
+
// Model ids explicitly marked deprecated across all pricing tables. Deprecated
|
|
15
|
+
// models are allowed to be absent from ContextLimits — they can be trimmed from
|
|
16
|
+
// Models.* / the context-limit map without breaking anything, since cost
|
|
17
|
+
// tracking reads the *TextPricing tables directly by model id.
|
|
18
|
+
const deprecatedModelIds = new Set<string>(
|
|
19
|
+
Object.entries({
|
|
20
|
+
...OpenAiTextPricing,
|
|
21
|
+
...GeminiTextPricing,
|
|
22
|
+
...AnthropicTextPricing,
|
|
23
|
+
...XaiTextPricing,
|
|
24
|
+
} as Record<string, { deprecated?: boolean }>)
|
|
25
|
+
.filter(([, p]) => p && p.deprecated === true)
|
|
26
|
+
.map(([id]) => id)
|
|
27
|
+
);
|
|
7
28
|
|
|
8
29
|
describe("ContextLimits", () => {
|
|
9
30
|
describe("coverage - all Models.* values have a recorded context limit", () => {
|
|
@@ -19,7 +40,8 @@ describe("ContextLimits", () => {
|
|
|
19
40
|
});
|
|
20
41
|
|
|
21
42
|
for (const [key, modelId] of modelEntries) {
|
|
22
|
-
|
|
43
|
+
const testFn = deprecatedModelIds.has(modelId) ? it.skip : it;
|
|
44
|
+
testFn(`${provider}.${key} (${modelId}) should have a context limit`, () => {
|
|
23
45
|
const limit = ContextLimits[modelId];
|
|
24
46
|
expect(limit).toBeDefined();
|
|
25
47
|
expect(typeof limit).toBe("number");
|
|
@@ -150,15 +150,34 @@ describe("Model Catalog Coverage", () => {
|
|
|
150
150
|
* Every model defined in Models.* and EmbeddingModels.* must have an entry
|
|
151
151
|
* in ALL_MODEL_CATALOG. This ensures the catalog stays in sync and is the
|
|
152
152
|
* single source of truth for model metadata and pricing.
|
|
153
|
+
*
|
|
154
|
+
* Exception: models flagged `deprecated` in their pricing entry are allowed to
|
|
155
|
+
* be absent from the catalog. Cost tracking for historical usage still works
|
|
156
|
+
* because calculateCost() reads the *TextPricing tables directly by model id,
|
|
157
|
+
* independently of the catalog — so deprecated models can be trimmed from the
|
|
158
|
+
* catalog/Models.* without breaking cost calculation.
|
|
153
159
|
*/
|
|
154
160
|
const catalogIds = new Set(ALL_MODEL_CATALOG.map((m) => m.id));
|
|
155
161
|
|
|
162
|
+
// Model ids explicitly marked deprecated across all pricing tables.
|
|
163
|
+
const deprecatedModelIds = new Set<string>(
|
|
164
|
+
Object.entries({
|
|
165
|
+
...OpenAiTextPricing,
|
|
166
|
+
...GeminiTextPricing,
|
|
167
|
+
...AnthropicTextPricing,
|
|
168
|
+
...XaiTextPricing,
|
|
169
|
+
} as Record<string, { deprecated?: boolean }>)
|
|
170
|
+
.filter(([, p]) => p && p.deprecated === true)
|
|
171
|
+
.map(([id]) => id)
|
|
172
|
+
);
|
|
173
|
+
|
|
156
174
|
describe("All Models.* entries are in the catalog", () => {
|
|
157
175
|
for (const [provider, providerModels] of Object.entries(Models)) {
|
|
158
176
|
for (const [modelKey, modelId] of Object.entries(
|
|
159
177
|
providerModels as Record<string, string>
|
|
160
178
|
)) {
|
|
161
|
-
|
|
179
|
+
const testFn = deprecatedModelIds.has(modelId) ? it.skip : it;
|
|
180
|
+
testFn(`Models.${provider}.${modelKey} (${modelId}) is in ALL_MODEL_CATALOG`, () => {
|
|
162
181
|
expect(catalogIds.has(modelId)).toBe(true);
|
|
163
182
|
});
|
|
164
183
|
}
|