itermbot 1.0.2 → 1.0.4
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/.github/workflows/ci.yml +15 -20
- package/.github/workflows/release.yml +32 -20
- package/README.md +11 -20
- package/cleanup-unused.patch +108 -0
- package/config/app.yaml +32 -13
- package/config/memory.yaml +38 -31
- package/config/model.yaml +33 -0
- package/config/skill.yaml +8 -0
- package/config/tool.yaml +50 -17
- package/config/tsconfig.json +4 -1
- package/dist/chat/builtin-commands.d.ts +8 -0
- package/dist/chat/builtin-commands.d.ts.map +1 -0
- package/dist/chat/builtin-commands.js +53 -0
- package/dist/chat/builtin-commands.js.map +1 -0
- package/dist/chat/progress.d.ts +3 -0
- package/dist/chat/progress.d.ts.map +1 -0
- package/dist/chat/progress.js +23 -0
- package/dist/chat/progress.js.map +1 -0
- package/dist/chat/response-safety.d.ts +8 -0
- package/dist/chat/response-safety.d.ts.map +1 -0
- package/dist/chat/response-safety.js +126 -0
- package/dist/chat/response-safety.js.map +1 -0
- package/dist/chat/step-display.d.ts +2 -0
- package/dist/chat/step-display.d.ts.map +1 -0
- package/dist/chat/step-display.js +50 -0
- package/dist/chat/step-display.js.map +1 -0
- package/dist/chat/tool-result.d.ts +4 -0
- package/dist/chat/tool-result.d.ts.map +1 -0
- package/dist/chat/tool-result.js +24 -0
- package/dist/chat/tool-result.js.map +1 -0
- package/dist/config.d.ts +11 -6
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +26 -12
- package/dist/config.js.map +1 -1
- package/dist/index.js +308 -151
- package/dist/index.js.map +1 -1
- package/dist/iterm/direct-command-router.d.ts +24 -0
- package/dist/iterm/direct-command-router.d.ts.map +1 -0
- package/dist/iterm/direct-command-router.js +213 -0
- package/dist/iterm/direct-command-router.js.map +1 -0
- package/dist/iterm/session-hint.d.ts +10 -0
- package/dist/iterm/session-hint.d.ts.map +1 -0
- package/dist/iterm/session-hint.js +43 -0
- package/dist/iterm/session-hint.js.map +1 -0
- package/dist/iterm/target-panel-policy.d.ts +12 -0
- package/dist/iterm/target-panel-policy.d.ts.map +1 -0
- package/dist/iterm/target-panel-policy.js +287 -0
- package/dist/iterm/target-panel-policy.js.map +1 -0
- package/dist/runtime/text-tool-call-recovery.d.ts +23 -0
- package/dist/runtime/text-tool-call-recovery.d.ts.map +1 -0
- package/dist/runtime/text-tool-call-recovery.js +211 -0
- package/dist/runtime/text-tool-call-recovery.js.map +1 -0
- package/dist/startup/colors.d.ts +37 -0
- package/dist/startup/colors.d.ts.map +1 -0
- package/dist/{startup-colors.js → startup/colors.js} +30 -15
- package/dist/startup/colors.js.map +1 -0
- package/dist/startup/diagnostics.d.ts +8 -0
- package/dist/startup/diagnostics.d.ts.map +1 -0
- package/dist/startup/diagnostics.js +18 -0
- package/dist/startup/diagnostics.js.map +1 -0
- package/dist/startup/os.d.ts +10 -0
- package/dist/startup/os.d.ts.map +1 -0
- package/dist/startup/os.js +67 -0
- package/dist/startup/os.js.map +1 -0
- package/dist/startup/ui.d.ts +11 -0
- package/dist/startup/ui.d.ts.map +1 -0
- package/dist/startup/ui.js +49 -0
- package/dist/startup/ui.js.map +1 -0
- package/package.json +23 -13
- package/scripts/internal-package-refs.mjs +158 -0
- package/scripts/patch-buildin-cache.sh +1 -4
- package/scripts/resolve-deps.js +5 -0
- package/scripts/test-llm.mjs +11 -5
- package/skills/gpu-ssh-monitor/SKILL.md +22 -3
- package/src/chat/builtin-commands.ts +70 -0
- package/src/chat/progress.ts +26 -0
- package/src/chat/response-safety.ts +134 -0
- package/src/chat/step-display.ts +54 -0
- package/src/chat/tool-result.ts +22 -0
- package/src/config.ts +48 -21
- package/src/index.ts +377 -167
- package/src/iterm/direct-command-router.ts +274 -0
- package/src/iterm/session-hint.ts +49 -0
- package/src/iterm/target-panel-policy.ts +341 -0
- package/src/runtime/text-tool-call-recovery.ts +257 -0
- package/src/{startup-colors.ts → startup/colors.ts} +42 -27
- package/src/startup/diagnostics.ts +25 -0
- package/src/startup/os.ts +63 -0
- package/src/startup/ui.ts +56 -0
- package/src/types/marked-terminal.d.ts +3 -0
- package/test/builtin-commands.test.mjs +50 -0
- package/test/chat-flow.integration.test.mjs +235 -0
- package/test/chat-progress.test.mjs +83 -0
- package/test/config.test.mjs +22 -0
- package/test/diagnostics.test.mjs +45 -0
- package/test/direct-command-router.test.mjs +149 -0
- package/test/live-iterm-llm.integration.test.mjs +153 -0
- package/test/response-safety.test.mjs +44 -0
- package/test/session-hint.test.mjs +78 -0
- package/test/startup-colors.test.mjs +145 -0
- package/test/target-panel-policy.test.mjs +180 -0
- package/test/tool-call-recovery.test.mjs +199 -0
- package/config/agent.yaml +0 -121
- package/config/models.yaml +0 -36
- package/config/skills.yaml +0 -4
- package/dist/agent.d.ts +0 -14
- package/dist/agent.d.ts.map +0 -1
- package/dist/agent.js +0 -16
- package/dist/agent.js.map +0 -1
- package/dist/context.d.ts +0 -12
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -20
- package/dist/context.js.map +0 -1
- package/dist/session-hint.d.ts +0 -4
- package/dist/session-hint.d.ts.map +0 -1
- package/dist/session-hint.js +0 -25
- package/dist/session-hint.js.map +0 -1
- package/dist/startup-colors.d.ts +0 -26
- package/dist/startup-colors.d.ts.map +0 -1
- package/dist/startup-colors.js.map +0 -1
- package/dist/target-routing.d.ts +0 -15
- package/dist/target-routing.d.ts.map +0 -1
- package/dist/target-routing.js +0 -355
- package/dist/target-routing.js.map +0 -1
- package/src/agent.ts +0 -35
- package/src/context.ts +0 -35
- package/src/session-hint.ts +0 -28
- package/src/target-routing.ts +0 -419
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { AgentContextTokens } from "@easynet/agent-common/context";
|
|
4
|
+
import { tryHandleDirectTargetPanelCommand } from "../dist/iterm/direct-command-router.js";
|
|
5
|
+
import { runWithTextToolCallRecovery } from "../dist/runtime/text-tool-call-recovery.js";
|
|
6
|
+
|
|
7
|
+
function makeRuntime({ tools = [], model, runImpl }) {
|
|
8
|
+
return {
|
|
9
|
+
context: {
|
|
10
|
+
get(token) {
|
|
11
|
+
if (token === AgentContextTokens.Tools) return tools;
|
|
12
|
+
if (token === AgentContextTokens.ChatModel) return model;
|
|
13
|
+
return undefined;
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
run: runImpl,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("integration: natural language does not execute as direct shell by default", async () => {
|
|
21
|
+
const prev = process.env.ITB_ENABLE_NL_DIRECT;
|
|
22
|
+
delete process.env.ITB_ENABLE_NL_DIRECT;
|
|
23
|
+
|
|
24
|
+
const runtime = makeRuntime({
|
|
25
|
+
tools: [
|
|
26
|
+
{
|
|
27
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.itermRunCommandInSession",
|
|
28
|
+
async invoke() {
|
|
29
|
+
throw new Error("should not invoke direct shell for natural language by default");
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
model: {
|
|
34
|
+
async invoke() {
|
|
35
|
+
throw new Error("translator should not run when NL direct is disabled");
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
runImpl: async () => ({ text: "unused" }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const out = await tryHandleDirectTargetPanelCommand("please continue the analysis", runtime);
|
|
42
|
+
assert.equal(out, null);
|
|
43
|
+
|
|
44
|
+
if (prev === undefined) delete process.env.ITB_ENABLE_NL_DIRECT;
|
|
45
|
+
else process.env.ITB_ENABLE_NL_DIRECT = prev;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("integration: runtime -> tool-call recovery -> final answer", async () => {
|
|
49
|
+
const toolCalls = [];
|
|
50
|
+
const runtime = makeRuntime({
|
|
51
|
+
tools: [
|
|
52
|
+
{
|
|
53
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.listDir",
|
|
54
|
+
async invoke(args) {
|
|
55
|
+
toolCalls.push(args);
|
|
56
|
+
return {
|
|
57
|
+
result: {
|
|
58
|
+
blockedTool: "npm.easynet.agent.tool.buildin.0.0.70.listDir",
|
|
59
|
+
redirected: true,
|
|
60
|
+
requestedPath: "sandbox",
|
|
61
|
+
resolvedPath: ".",
|
|
62
|
+
pathFallbackUsed: true,
|
|
63
|
+
output: { result: { output: "[itermbot] path not found: sandbox; fallback to .\n./README.md\n./src" } },
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
model: null,
|
|
70
|
+
runImpl: async () => ({
|
|
71
|
+
text: "<tool-call>{\"name\":\"listDir\",\"arguments\":{\"path\":\"sandbox\",\"maxEntries\":10,\"recursive\":true}}</tool-call>",
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const out = await runWithTextToolCallRecovery(runtime, "list files and do analysis", () => {});
|
|
76
|
+
assert.equal(out.recovered, true);
|
|
77
|
+
assert.equal(toolCalls.length, 1);
|
|
78
|
+
assert.equal(toolCalls[0].path, "sandbox");
|
|
79
|
+
assert.equal(out.text.includes("### Recovered Tool Result"), true);
|
|
80
|
+
assert.equal(out.text.includes("pathFallbackUsed: true"), true);
|
|
81
|
+
assert.equal(out.text.includes("resolvedPath: ."), true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("integration: redirected listDir result exposes plain output text for downstream reasoning", async () => {
|
|
85
|
+
const toolCalls = [];
|
|
86
|
+
const runtime = makeRuntime({
|
|
87
|
+
tools: [
|
|
88
|
+
{
|
|
89
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.listDir",
|
|
90
|
+
async invoke(args) {
|
|
91
|
+
toolCalls.push(args);
|
|
92
|
+
return {
|
|
93
|
+
result: {
|
|
94
|
+
blockedTool: "npm.easynet.agent.tool.buildin.0.0.70.listDir",
|
|
95
|
+
redirected: true,
|
|
96
|
+
requestedPath: ".",
|
|
97
|
+
resolvedPath: ".",
|
|
98
|
+
pathFallbackUsed: false,
|
|
99
|
+
outputText: "./README.md\n./src",
|
|
100
|
+
commandCompleted: true,
|
|
101
|
+
exitCode: 0,
|
|
102
|
+
output: { result: { output: "./README.md\n./src", commandCompleted: true, exitCode: 0 } },
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
model: null,
|
|
109
|
+
runImpl: async () => ({
|
|
110
|
+
text: "<tool-call>{\"name\":\"listDir\",\"arguments\":{\"path\":\".\",\"maxEntries\":10,\"recursive\":true}}</tool-call>",
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const out = await runWithTextToolCallRecovery(runtime, "list files", () => {});
|
|
115
|
+
assert.equal(out.recovered, true);
|
|
116
|
+
assert.equal(toolCalls.length, 1);
|
|
117
|
+
assert.equal(out.text.includes("requestedPath: ."), true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("integration: no tool activity returns first-pass text without external recovery", async () => {
|
|
121
|
+
const calls = [];
|
|
122
|
+
const runtime = makeRuntime({
|
|
123
|
+
tools: [
|
|
124
|
+
{
|
|
125
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.itermRunCommandInSession",
|
|
126
|
+
async invoke(args) {
|
|
127
|
+
calls.push(args);
|
|
128
|
+
return { result: { output: "/workspace\nFilesystem Size Used Avail Use%\n/dev/disk 100G 50G 50G 50%\n" } };
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
model: null,
|
|
133
|
+
runImpl: async () => ({
|
|
134
|
+
text: "Please specify a path to continue.",
|
|
135
|
+
messages: [{ type: "ai", content: "Please specify a path to continue." }],
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const out = await runWithTextToolCallRecovery(runtime, "check disk usage", () => {}, {
|
|
140
|
+
windowId: 9,
|
|
141
|
+
tabIndex: 2,
|
|
142
|
+
sessionId: "target-session",
|
|
143
|
+
});
|
|
144
|
+
assert.equal(out.recovered, false);
|
|
145
|
+
assert.equal(calls.length, 0);
|
|
146
|
+
assert.equal(out.text, "Please specify a path to continue.");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("integration: no tool activity does not trigger target-panel fallback execution", async () => {
|
|
150
|
+
const calls = [];
|
|
151
|
+
const runtime = makeRuntime({
|
|
152
|
+
tools: [
|
|
153
|
+
{
|
|
154
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.itermRunCommandInSession",
|
|
155
|
+
async invoke(args) {
|
|
156
|
+
calls.push(args);
|
|
157
|
+
return { result: { output: "/workspace\nFilesystem Size Used Avail Use%\n/dev/disk 100G 30G 70G 30%\n" } };
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
model: null,
|
|
162
|
+
runImpl: async () => ({
|
|
163
|
+
text: "Need more context",
|
|
164
|
+
messages: [{ type: "ai", content: "Need more context" }],
|
|
165
|
+
}),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const out = await runWithTextToolCallRecovery(runtime, "list tools", () => {}, {
|
|
169
|
+
windowId: 1,
|
|
170
|
+
tabIndex: 0,
|
|
171
|
+
sessionId: "target",
|
|
172
|
+
});
|
|
173
|
+
assert.equal(out.recovered, false);
|
|
174
|
+
assert.equal(calls.length, 0);
|
|
175
|
+
assert.equal(out.text, "Need more context");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("integration: tool-activity first pass returns directly without bootstrap recovery", async () => {
|
|
179
|
+
const calls = [];
|
|
180
|
+
const runtime = makeRuntime({
|
|
181
|
+
tools: [
|
|
182
|
+
{
|
|
183
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.itermRunCommandInSession",
|
|
184
|
+
async invoke(args) {
|
|
185
|
+
calls.push(args);
|
|
186
|
+
return { result: { output: "/workspace\nFilesystem Size Used Avail Use%\n/dev/disk 100G 70G 30G 70%\n" } };
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
model: null,
|
|
191
|
+
runImpl: async () => ({
|
|
192
|
+
text: "The current tool output only includes a file list and does not include disk usage metrics.",
|
|
193
|
+
messages: [{ type: "tool", name: "listDir", content: "..." }],
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const out = await runWithTextToolCallRecovery(runtime, "check disk usage", () => {}, {
|
|
198
|
+
windowId: 3,
|
|
199
|
+
tabIndex: 1,
|
|
200
|
+
sessionId: "target-session",
|
|
201
|
+
});
|
|
202
|
+
assert.equal(out.recovered, false);
|
|
203
|
+
assert.equal(calls.length, 0);
|
|
204
|
+
assert.equal(out.text.includes("does not include disk usage metrics"), true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("integration: listDir-only first pass stays on main agent path without extra recovery", async () => {
|
|
208
|
+
const calls = [];
|
|
209
|
+
const runtime = makeRuntime({
|
|
210
|
+
tools: [
|
|
211
|
+
{
|
|
212
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.itermRunCommandInSession",
|
|
213
|
+
async invoke(args) {
|
|
214
|
+
calls.push(args);
|
|
215
|
+
return { result: { output: "/workspace\nFilesystem Size Used Avail Use%\n/dev/disk 100G 80G 20G 80%\n" } };
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
model: null,
|
|
220
|
+
runImpl: async () => ({
|
|
221
|
+
text: "The current tool output only includes a file list and does not include disk usage metrics. To inspect disk usage, adjust tool parameters.",
|
|
222
|
+
messages: [{ type: "tool", name: "listDir", content: "..." }],
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const out = await runWithTextToolCallRecovery(runtime, "check disk usage", () => {}, {
|
|
227
|
+
windowId: 6,
|
|
228
|
+
tabIndex: 2,
|
|
229
|
+
sessionId: "target-regression",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
assert.equal(out.recovered, false);
|
|
233
|
+
assert.equal(calls.length, 0);
|
|
234
|
+
assert.equal(out.text.includes("does not include disk usage metrics"), true);
|
|
235
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createChatProgressEventListener } from "../dist/chat/progress.js";
|
|
4
|
+
|
|
5
|
+
function makeEvent(name, payload = {}, to = "tool") {
|
|
6
|
+
return {
|
|
7
|
+
id: "evt-1",
|
|
8
|
+
at: new Date().toISOString(),
|
|
9
|
+
from: "react-agent",
|
|
10
|
+
to,
|
|
11
|
+
name,
|
|
12
|
+
payload,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("chat progress listener shows step lines and progress updates", () => {
|
|
17
|
+
const lines = [];
|
|
18
|
+
const listener = createChatProgressEventListener((line) => lines.push(line));
|
|
19
|
+
|
|
20
|
+
listener(makeEvent("agent.react.run.start"));
|
|
21
|
+
listener(makeEvent("agent.react.tool.invoke.start", { args: { command: "ls -la" } }, "bash"));
|
|
22
|
+
listener(makeEvent("agent.react.tool.invoke.done", { result: { output: "ok" } }, "bash"));
|
|
23
|
+
listener(makeEvent("agent.react.run.done"));
|
|
24
|
+
|
|
25
|
+
assert.ok(lines.some((line) => line.includes("Steps: analysis")));
|
|
26
|
+
assert.ok(lines.some((line) => line.includes("[01]") && line.includes("run command: ls -la")));
|
|
27
|
+
assert.ok(lines.some((line) => line.includes("reason: Because we need verifiable terminal evidence")));
|
|
28
|
+
assert.ok(lines.some((line) => line.includes("[01]") && line.includes("✓") && line.includes("run command: ls -la")));
|
|
29
|
+
assert.ok(lines.some((line) => line.includes("progress 1/1")));
|
|
30
|
+
assert.ok(lines.some((line) => line.includes("Steps complete: 1 step(s)")));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("chat progress listener reports tool error from payload", () => {
|
|
34
|
+
const lines = [];
|
|
35
|
+
const listener = createChatProgressEventListener((line) => lines.push(line));
|
|
36
|
+
|
|
37
|
+
listener(makeEvent("agent.react.run.start"));
|
|
38
|
+
listener(makeEvent("agent.react.tool.invoke.start", { args: { command: "bad" } }, "bash"));
|
|
39
|
+
listener(makeEvent("agent.react.tool.invoke.done", { result: { result: { error: "permission denied" } } }, "bash"));
|
|
40
|
+
|
|
41
|
+
assert.ok(lines.some((line) => line.includes("✖")));
|
|
42
|
+
assert.ok(lines.some((line) => line.includes("error: permission denied")));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("chat progress listener infers list directory action from args shape", () => {
|
|
46
|
+
const lines = [];
|
|
47
|
+
const listener = createChatProgressEventListener((line) => lines.push(line));
|
|
48
|
+
|
|
49
|
+
listener(makeEvent("agent.react.run.start"));
|
|
50
|
+
listener(makeEvent("agent.react.tool.invoke.start", {
|
|
51
|
+
args: { path: "sandbox", recursive: true, maxEntries: 10, maxDepth: 2, includeHidden: true },
|
|
52
|
+
}, "tool_1"));
|
|
53
|
+
listener(makeEvent("agent.react.tool.invoke.done", { result: { output: "ok" } }, "tool_1"));
|
|
54
|
+
|
|
55
|
+
assert.ok(lines.some((line) => line.includes("[01]") && line.includes("list directory: sandbox")));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("chat progress listener shows explicit resultPreview when present", () => {
|
|
59
|
+
const lines = [];
|
|
60
|
+
const listener = createChatProgressEventListener((line) => lines.push(line));
|
|
61
|
+
|
|
62
|
+
listener(makeEvent("agent.react.run.start"));
|
|
63
|
+
listener(makeEvent("agent.react.tool.invoke.start", { args: { path: "." } }, "tool_1"));
|
|
64
|
+
listener(makeEvent("agent.react.tool.invoke.done", { resultPreview: "Directory listing for .: README.md src" }, "tool_1"));
|
|
65
|
+
|
|
66
|
+
assert.ok(lines.some((line) => line.includes("[01]") && line.includes("✓") && line.includes("read path: .")));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("chat progress listener reports skill matched", () => {
|
|
70
|
+
const lines = [];
|
|
71
|
+
const listener = createChatProgressEventListener((line) => lines.push(line));
|
|
72
|
+
|
|
73
|
+
listener(makeEvent("agent.react.skill.matched", { skill: "coding" }, "skill"));
|
|
74
|
+
assert.ok(lines.some((line) => line.includes("skill: coding")));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("chat progress listener shows ptc retry attempt from payload", () => {
|
|
78
|
+
const lines = [];
|
|
79
|
+
const listener = createChatProgressEventListener((line) => lines.push(line));
|
|
80
|
+
|
|
81
|
+
listener(makeEvent("agent.react.ptc.retry", { retry: 2, reason: "no_tool_activity" }));
|
|
82
|
+
assert.ok(lines.some((line) => line.includes("planning retry: attempt 2, reason=no_tool_activity")));
|
|
83
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { loadAppConfig } from "../dist/config.js";
|
|
5
|
+
|
|
6
|
+
test("loadAppConfig reads prompt templates from app config", async () => {
|
|
7
|
+
const configPath = resolve(process.cwd(), "config/app.yaml");
|
|
8
|
+
const config = await loadAppConfig(configPath);
|
|
9
|
+
|
|
10
|
+
assert.equal(typeof config, "object");
|
|
11
|
+
assert.equal(typeof config.promptTemplates?.systemPrompt, "string");
|
|
12
|
+
assert.equal(config.responseSafetyMode, "balanced");
|
|
13
|
+
assert.match(config.promptTemplates?.systemPrompt ?? "", /iTerm Operating Policy/);
|
|
14
|
+
assert.match(config.promptTemplates?.systemPrompt ?? "", /Skills are preferred/);
|
|
15
|
+
assert.match(config.promptTemplates?.systemPrompt ?? "", /\{\{targetSessionSection\}\}/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("loadAppConfig returns empty object when file does not exist", async () => {
|
|
19
|
+
const configPath = resolve(process.cwd(), "config/does-not-exist.yaml");
|
|
20
|
+
const config = await loadAppConfig(configPath);
|
|
21
|
+
assert.deepEqual(config, {});
|
|
22
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { runLlmHealthCheck } from "../dist/startup/diagnostics.js";
|
|
4
|
+
|
|
5
|
+
function makeRuntimeWithInvoke(invoke) {
|
|
6
|
+
return {
|
|
7
|
+
context: {
|
|
8
|
+
get() {
|
|
9
|
+
return { invoke };
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("runLlmHealthCheck returns trimmed text for string content", async () => {
|
|
16
|
+
const runtime = makeRuntimeWithInvoke(async () => ({ content: " OK " }));
|
|
17
|
+
const out = await runLlmHealthCheck(runtime);
|
|
18
|
+
assert.equal(out, "OK");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("runLlmHealthCheck supports array content and truncates long output", async () => {
|
|
22
|
+
const runtime = makeRuntimeWithInvoke(async () => ({
|
|
23
|
+
content: [{ text: "0123456789012345678901234567890123456789" }],
|
|
24
|
+
}));
|
|
25
|
+
const out = await runLlmHealthCheck(runtime);
|
|
26
|
+
assert.equal(out.length <= 35, true);
|
|
27
|
+
assert.equal(out.endsWith("..."), true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("runLlmHealthCheck rejects on empty response", async () => {
|
|
31
|
+
const runtime = makeRuntimeWithInvoke(async () => ({ content: [] }));
|
|
32
|
+
await assert.rejects(() => runLlmHealthCheck(runtime), /empty response/i);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("runLlmHealthCheck rejects on timeout", async () => {
|
|
36
|
+
const prev = process.env.ITERMBOT_LLM_HEALTHCHECK_TIMEOUT_MS;
|
|
37
|
+
process.env.ITERMBOT_LLM_HEALTHCHECK_TIMEOUT_MS = "20";
|
|
38
|
+
try {
|
|
39
|
+
const runtime = makeRuntimeWithInvoke(async () => await new Promise(() => {}));
|
|
40
|
+
await assert.rejects(() => runLlmHealthCheck(runtime), /timeout/i);
|
|
41
|
+
} finally {
|
|
42
|
+
if (prev === undefined) delete process.env.ITERMBOT_LLM_HEALTHCHECK_TIMEOUT_MS;
|
|
43
|
+
else process.env.ITERMBOT_LLM_HEALTHCHECK_TIMEOUT_MS = prev;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import test, { beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { AgentContextTokens } from "@easynet/agent-common/context";
|
|
4
|
+
import {
|
|
5
|
+
detectDirectTargetPanelCommand,
|
|
6
|
+
tryHandleDirectTargetPanelCommand,
|
|
7
|
+
} from "../dist/iterm/direct-command-router.js";
|
|
8
|
+
|
|
9
|
+
function makeRuntime(tools, model = null) {
|
|
10
|
+
return {
|
|
11
|
+
context: {
|
|
12
|
+
get(token) {
|
|
13
|
+
if (token === AgentContextTokens.Tools) return tools;
|
|
14
|
+
if (token === AgentContextTokens.ChatModel) return model;
|
|
15
|
+
return undefined;
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
delete process.env.ITB_ENABLE_NL_DIRECT;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("detectDirectTargetPanelCommand recognizes raw shell command", () => {
|
|
26
|
+
const out = detectDirectTargetPanelCommand("ls -la");
|
|
27
|
+
assert.equal(out?.reason, "raw_shell_command");
|
|
28
|
+
assert.equal(out?.command, "ls -la");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("detectDirectTargetPanelCommand ignores natural-language requests", () => {
|
|
32
|
+
const out = detectDirectTargetPanelCommand("list files in the repo");
|
|
33
|
+
assert.equal(out, null);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("tryHandleDirectTargetPanelCommand invokes itermRunCommandInSession and returns summarized output by default", async () => {
|
|
37
|
+
const calls = [];
|
|
38
|
+
const runtime = makeRuntime([
|
|
39
|
+
{
|
|
40
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.itermRunCommandInSession",
|
|
41
|
+
async invoke(args) {
|
|
42
|
+
calls.push(args);
|
|
43
|
+
return { result: { output: "fileA\nfileB" } };
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const out = await tryHandleDirectTargetPanelCommand("ls -la", runtime, {
|
|
49
|
+
windowId: 7,
|
|
50
|
+
tabIndex: 1,
|
|
51
|
+
sessionId: "target",
|
|
52
|
+
});
|
|
53
|
+
assert.equal(out.includes("### Command Result"), true);
|
|
54
|
+
assert.equal(out.includes("- Command: `ls -la`"), true);
|
|
55
|
+
assert.equal(out.includes("- Captured lines: **2**"), true);
|
|
56
|
+
assert.equal(out.includes("- Raw output: hidden by default"), true);
|
|
57
|
+
assert.equal(calls.length, 1);
|
|
58
|
+
assert.equal(calls[0].command, "ls -la");
|
|
59
|
+
assert.equal(calls[0].windowId, 7);
|
|
60
|
+
assert.equal(calls[0].tabIndex, 1);
|
|
61
|
+
assert.equal(calls[0].sessionId, "target");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("tryHandleDirectTargetPanelCommand returns null when command tool is missing", async () => {
|
|
65
|
+
const runtime = makeRuntime([]);
|
|
66
|
+
const out = await tryHandleDirectTargetPanelCommand("list files", runtime);
|
|
67
|
+
assert.equal(out, null);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("tryHandleDirectTargetPanelCommand does not route natural-language requests", async () => {
|
|
71
|
+
const runtime = makeRuntime(
|
|
72
|
+
[
|
|
73
|
+
{
|
|
74
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.itermRunCommandInSession",
|
|
75
|
+
async invoke() {
|
|
76
|
+
throw new Error("should not run");
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
{
|
|
81
|
+
async invoke() {
|
|
82
|
+
throw new Error("should not consult model");
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const out = await tryHandleDirectTargetPanelCommand("show workspace tree", runtime);
|
|
88
|
+
assert.equal(out, null);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("single-line output includes value in command summary", async () => {
|
|
92
|
+
const runtime = makeRuntime([
|
|
93
|
+
{
|
|
94
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.itermRunCommandInSession",
|
|
95
|
+
async invoke() {
|
|
96
|
+
return { result: { output: "42\n" } };
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
const out = await tryHandleDirectTargetPanelCommand("echo -n 42", runtime);
|
|
101
|
+
assert.equal(out.includes("- Value: `42`"), true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("direct shell command returns summary (not inline raw output by suffix text)", async () => {
|
|
105
|
+
const runtime = makeRuntime([
|
|
106
|
+
{
|
|
107
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.itermRunCommandInSession",
|
|
108
|
+
async invoke() {
|
|
109
|
+
return { result: { output: "line-a\nline-b" } };
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
113
|
+
const out = await tryHandleDirectTargetPanelCommand("ls -la show raw output", runtime);
|
|
114
|
+
assert.equal(out.includes("### Command Result"), true);
|
|
115
|
+
assert.equal(out.includes("Raw output: hidden by default"), true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("without raw shell syntax, direct execution stays off", async () => {
|
|
119
|
+
const runtime = makeRuntime(
|
|
120
|
+
[
|
|
121
|
+
{
|
|
122
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.itermRunCommandInSession",
|
|
123
|
+
async invoke() {
|
|
124
|
+
throw new Error("should not run");
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
null,
|
|
129
|
+
);
|
|
130
|
+
const out = await tryHandleDirectTargetPanelCommand("show workspace tree", runtime);
|
|
131
|
+
assert.equal(out, null);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("analysis-oriented mixed request falls back to agent (returns null)", async () => {
|
|
135
|
+
const runtime = makeRuntime(
|
|
136
|
+
[
|
|
137
|
+
{
|
|
138
|
+
name: "npm.easynet.agent.tool.buildin.0.0.70.itermRunCommandInSession",
|
|
139
|
+
async invoke() {
|
|
140
|
+
throw new Error("should not run");
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
null,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const out = await tryHandleDirectTargetPanelCommand("list files and do analysis", runtime);
|
|
148
|
+
assert.equal(out, null);
|
|
149
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import {
|
|
5
|
+
createAgentEventBus,
|
|
6
|
+
} from "@easynet/agent-common";
|
|
7
|
+
import { resolveConfigPath } from "@easynet/agent-common/config";
|
|
8
|
+
import { getDefaultAgentContext } from "@easynet/agent-common/context";
|
|
9
|
+
import { createAgentModel } from "@easynet/agent-model";
|
|
10
|
+
import { createAgentTools } from "@easynet/agent-tool";
|
|
11
|
+
import { itermListCurrentWindowSessions } from "@easynet/agent-tool-buildin/iterm";
|
|
12
|
+
import { runLlmHealthCheck } from "../dist/startup/diagnostics.js";
|
|
13
|
+
import { tryHandleDirectTargetPanelCommand } from "../dist/iterm/direct-command-router.js";
|
|
14
|
+
import { runWithTextToolCallRecovery } from "../dist/runtime/text-tool-call-recovery.js";
|
|
15
|
+
|
|
16
|
+
const RUN_LIVE = process.env.RUN_LIVE_ITERM_LLM === "1";
|
|
17
|
+
const isDarwin = process.platform === "darwin";
|
|
18
|
+
|
|
19
|
+
function hasCommand(cmd) {
|
|
20
|
+
return spawnSync("which", [cmd], { stdio: "ignore" }).status === 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasIterm2Installed() {
|
|
24
|
+
const check = spawnSync("osascript", ["-e", 'id of app "iTerm"'], { encoding: "utf-8" });
|
|
25
|
+
return check.status === 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pickTargetHintFromCurrentWindow(result) {
|
|
29
|
+
const sessions = Array.isArray(result?.sessions) ? result.sessions : [];
|
|
30
|
+
const nonCurrent = sessions.find((s) => !s?.isCurrentSession && (s?.sessionUniqueId || s?.sessionId));
|
|
31
|
+
const current = sessions.find((s) => s?.isCurrentSession && (s?.sessionUniqueId || s?.sessionId));
|
|
32
|
+
const target = nonCurrent ?? current ?? null;
|
|
33
|
+
if (!target) return null;
|
|
34
|
+
return {
|
|
35
|
+
windowId: result?.windowId ?? target.windowId,
|
|
36
|
+
tabIndex: target.tabIndex,
|
|
37
|
+
sessionId: target.sessionUniqueId ?? target.sessionId,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const liveGuardReason = !RUN_LIVE
|
|
42
|
+
? "set RUN_LIVE_ITERM_LLM=1 to run live iTerm+LLM tests"
|
|
43
|
+
: !isDarwin
|
|
44
|
+
? "live iTerm tests require macOS"
|
|
45
|
+
: !hasCommand("osascript")
|
|
46
|
+
? "osascript not found"
|
|
47
|
+
: !hasIterm2Installed()
|
|
48
|
+
? "iTerm2 is not installed or not discoverable"
|
|
49
|
+
: "";
|
|
50
|
+
|
|
51
|
+
const shouldSkip = liveGuardReason.length > 0;
|
|
52
|
+
let contextInitPromise = null;
|
|
53
|
+
|
|
54
|
+
async function getLiveContext() {
|
|
55
|
+
if (!contextInitPromise) {
|
|
56
|
+
contextInitPromise = (async () => {
|
|
57
|
+
createAgentEventBus();
|
|
58
|
+
await createAgentModel({
|
|
59
|
+
configPath: resolveConfigPath("config/model.yaml", process.cwd()),
|
|
60
|
+
});
|
|
61
|
+
createAgentTools({
|
|
62
|
+
configFilePath: resolveConfigPath("config/tool.yaml", process.cwd()),
|
|
63
|
+
coreTools: { sandboxRoot: process.cwd() },
|
|
64
|
+
});
|
|
65
|
+
return getDefaultAgentContext();
|
|
66
|
+
})();
|
|
67
|
+
}
|
|
68
|
+
return contextInitPromise;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function getTargetHint() {
|
|
72
|
+
const sessions = await itermListCurrentWindowSessions();
|
|
73
|
+
const hint = pickTargetHintFromCurrentWindow(sessions.result);
|
|
74
|
+
assert.ok(hint, "No target/current iTerm session found");
|
|
75
|
+
return hint;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test("live: real LLM health check", { timeout: 120_000, skip: shouldSkip ? liveGuardReason : false }, async () => {
|
|
79
|
+
const context = await getLiveContext();
|
|
80
|
+
const runtimeLike = { context };
|
|
81
|
+
const health = await runLlmHealthCheck(runtimeLike);
|
|
82
|
+
assert.equal(typeof health, "string");
|
|
83
|
+
assert.equal(health.trim().length > 0, true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("live: real iTerm direct command routing executes in target panel", { timeout: 120_000, skip: shouldSkip ? liveGuardReason : false }, async () => {
|
|
87
|
+
const context = await getLiveContext();
|
|
88
|
+
const runtimeLike = { context };
|
|
89
|
+
const hint = await getTargetHint();
|
|
90
|
+
const marker = "__ITB_LIVE_DIRECT__";
|
|
91
|
+
const output = await tryHandleDirectTargetPanelCommand(`echo ${marker} && pwd`, runtimeLike, hint);
|
|
92
|
+
assert.equal(typeof output, "string");
|
|
93
|
+
assert.equal((output ?? "").trim().length > 0, true);
|
|
94
|
+
assert.equal((output ?? "").includes(marker), true);
|
|
95
|
+
assert.equal((output ?? "").includes("__ITB_BEGIN_"), false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("live: no-tool-activity bootstrap uses real model planning + real iTerm execution", { timeout: 120_000, skip: shouldSkip ? liveGuardReason : false }, async () => {
|
|
99
|
+
const context = await getLiveContext();
|
|
100
|
+
const hint = await getTargetHint();
|
|
101
|
+
const runtimeLike = {
|
|
102
|
+
context,
|
|
103
|
+
async run() {
|
|
104
|
+
return {
|
|
105
|
+
text: "Please specify a path.",
|
|
106
|
+
messages: [{ type: "ai", content: "Please specify a path." }],
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const out = await runWithTextToolCallRecovery(runtimeLike, "check disk usage", () => {}, hint);
|
|
111
|
+
assert.equal(out.recovered, true);
|
|
112
|
+
assert.equal(typeof out.text, "string");
|
|
113
|
+
assert.equal(out.text.includes("### Baseline Evidence"), true);
|
|
114
|
+
assert.equal(out.text.includes("Evidence Preview"), true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("live: literal tool-call text is recovered, executed, and finalized", { timeout: 120_000, skip: shouldSkip ? liveGuardReason : false }, async () => {
|
|
118
|
+
const context = await getLiveContext();
|
|
119
|
+
const hint = await getTargetHint();
|
|
120
|
+
const marker = "__ITB_LIVE_TOOLCALL__";
|
|
121
|
+
const runtimeLike = {
|
|
122
|
+
context,
|
|
123
|
+
async run() {
|
|
124
|
+
return {
|
|
125
|
+
text: `<tool-call>{"name":"itermRunCommandInSession","arguments":{"command":"echo ${marker} && pwd","maxOutputLines":120}}</tool-call>`,
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
const out = await runWithTextToolCallRecovery(runtimeLike, "execute one command and summarize output", () => {}, hint);
|
|
130
|
+
assert.equal(out.recovered, true);
|
|
131
|
+
assert.equal(typeof out.text, "string");
|
|
132
|
+
assert.equal(out.text.trim().length > 0, true);
|
|
133
|
+
assert.equal(out.text.includes("<tool-call>"), false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("live: multi-turn direct execution remains stable on same target hint", { timeout: 180_000, skip: shouldSkip ? liveGuardReason : false }, async () => {
|
|
137
|
+
const context = await getLiveContext();
|
|
138
|
+
const runtimeLike = { context };
|
|
139
|
+
const hint = await getTargetHint();
|
|
140
|
+
const markers = [
|
|
141
|
+
"__ITB_LIVE_MULTI_1__",
|
|
142
|
+
"__ITB_LIVE_MULTI_2__",
|
|
143
|
+
"__ITB_LIVE_MULTI_3__",
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
for (const marker of markers) {
|
|
147
|
+
const output = await tryHandleDirectTargetPanelCommand(`echo ${marker} && pwd`, runtimeLike, hint);
|
|
148
|
+
assert.equal(typeof output, "string");
|
|
149
|
+
assert.equal((output ?? "").trim().length > 0, true);
|
|
150
|
+
assert.equal((output ?? "").includes(marker), true);
|
|
151
|
+
assert.equal((output ?? "").includes("__ITB_BEGIN_"), false);
|
|
152
|
+
}
|
|
153
|
+
});
|