nolo-cli 0.1.18 → 0.1.20
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/README.md +9 -1
- package/agent-runtime/agentConfigOptions.ts +12 -0
- package/agent-runtime/agentRecordConfig.ts +99 -0
- package/agent-runtime/agentRecordKeys.ts +14 -0
- package/agent-runtime/dialogMessageRecord.ts +16 -0
- package/agent-runtime/dialogWritePlan.ts +130 -0
- package/agent-runtime/hostAdapter.ts +14 -0
- package/agent-runtime/hybridRecordStore.ts +147 -0
- package/agent-runtime/index.ts +69 -0
- package/agent-runtime/localLoop.ts +78 -6
- package/agent-runtime/localToolPolicy.ts +130 -0
- package/agent-runtime/localWorkspaceTools.ts +1532 -0
- package/agent-runtime/openAiCompatibleProvider.ts +70 -0
- package/agent-runtime/openAiCompatibleProviderConfig.ts +38 -0
- package/agent-runtime/platformChatProvider.ts +241 -0
- package/agent-runtime/taskWorkspace.ts +193 -0
- package/agent-runtime/types.ts +2 -0
- package/agent-runtime/workspaceSession.ts +76 -0
- package/agentAliases.ts +37 -0
- package/agentPullCommand.ts +1 -1
- package/agentRunCommand.ts +289 -54
- package/agentRuntimeCommands.ts +354 -164
- package/agentRuntimeLocal.ts +38 -0
- package/ai/agent/agentSlice.ts +10 -0
- package/ai/agent/buildEditingContext.ts +5 -0
- package/ai/agent/buildSystemPrompt.ts +41 -18
- package/ai/agent/canvasEditingContext.ts +49 -0
- package/ai/agent/cliExecutor.ts +15 -4
- package/ai/agent/createAgentSchema.ts +2 -0
- package/ai/agent/executeToolCall.ts +3 -2
- package/ai/agent/hooks/usePublicAgents.ts +6 -0
- package/ai/agent/pageBuilderHandoffRules.ts +75 -0
- package/ai/agent/runAgentClientLoop.ts +4 -1
- package/ai/agent/runtimeGuidance.ts +19 -0
- package/ai/agent/server/fetchPublicAgents.ts +51 -1
- package/ai/agent/streamAgentChatTurn.ts +20 -2
- package/ai/agent/streamAgentChatTurnUtils.ts +60 -16
- package/ai/chat/accumulateToolCallChunks.ts +40 -9
- package/ai/chat/parseApiError.ts +3 -0
- package/ai/chat/sendOpenAICompletionsRequest.native.ts +23 -10
- package/ai/chat/sendOpenAICompletionsRequest.ts +13 -1
- package/ai/chat/updateTotalUsage.ts +26 -9
- package/ai/llm/deepinfra.ts +51 -0
- package/ai/llm/getPricing.ts +6 -0
- package/ai/llm/kimi.ts +2 -0
- package/ai/llm/openrouterModels.ts +0 -135
- package/ai/llm/providers.ts +1 -0
- package/ai/llm/types.ts +8 -0
- package/ai/taskRun/taskRunProtocol.ts +823 -0
- package/ai/token/calculatePrice.ts +30 -0
- package/ai/token/externalToolCost.ts +49 -29
- package/ai/token/prepareTokenUsageData.ts +6 -1
- package/ai/token/serverTokenWriter.ts +4 -2
- package/ai/tools/agent/agentTools.ts +21 -0
- package/ai/tools/agent/presets/appBuilderPreset.ts +7 -0
- package/ai/tools/agent/streamParallelAgentsTool.ts +2 -1
- package/ai/tools/agent/taskRunTool.ts +112 -0
- package/ai/tools/applyEditTool.ts +6 -3
- package/ai/tools/applyLineEditsTool.ts +6 -3
- package/ai/tools/checkEnvTool.ts +14 -9
- package/ai/tools/codeSearchTool.ts +17 -5
- package/ai/tools/execBashTool.ts +33 -29
- package/ai/tools/fetchWebpageSupport.ts +24 -0
- package/ai/tools/fetchWebpageTool.ts +18 -5
- package/ai/tools/index.ts +158 -0
- package/ai/tools/jdProductScraperTool.ts +821 -0
- package/ai/tools/listFilesTool.ts +6 -3
- package/ai/tools/localFilesTool.ts +200 -0
- package/ai/tools/readFileTool.ts +6 -3
- package/ai/tools/searchRepoTool.ts +6 -3
- package/ai/tools/table/rowTools.ts +6 -1
- package/ai/tools/taobaoTmallProductScraperTool.ts +49 -0
- package/ai/tools/toolApiClient.ts +20 -6
- package/ai/tools/wereadGatewayTool.ts +152 -0
- package/ai/tools/writeFileTool.ts +6 -3
- package/client/agentConfigResolver.test.ts +70 -0
- package/client/agentConfigResolver.ts +1 -0
- package/client/agentRun.test.ts +361 -7
- package/client/agentRun.ts +449 -63
- package/client/hybridRecordStore.test.ts +115 -0
- package/client/hybridRecordStore.ts +41 -0
- package/client/localAgentRecords.test.ts +27 -0
- package/client/localAgentRecords.ts +7 -0
- package/client/localDialogRecords.test.ts +124 -0
- package/client/localDialogRecords.ts +30 -0
- package/client/localProviderResolver.test.ts +78 -0
- package/client/localProviderResolver.ts +1 -0
- package/client/localRuntimeAdapter.test.ts +813 -20
- package/client/localRuntimeAdapter.ts +279 -232
- package/client/localRuntimeDryRun.test.ts +116 -0
- package/client/localToolPolicy.ts +8 -81
- package/client/taskRunPrompt.ts +26 -0
- package/client/taskWorktree.ts +8 -0
- package/client/workspaceSession.test.ts +57 -0
- package/client/workspaceSession.ts +11 -0
- package/commandRegistry.ts +23 -6
- package/connectorRunArtifact.ts +121 -0
- package/database/actions/write.ts +16 -2
- package/database/hooks/useUserData.ts +9 -3
- package/database/server/dataHandlers.ts +18 -20
- package/database/server/emailRepository.ts +3 -3
- package/database/server/patch.ts +18 -10
- package/database/server/query.ts +43 -4
- package/database/server/read.ts +24 -38
- package/database/server/recordIdentity.ts +100 -0
- package/database/server/write.ts +21 -25
- package/index.ts +70 -33
- package/machineCommands.ts +318 -144
- package/package.json +4 -1
- package/tableCommands.ts +181 -0
- package/taskRunCommand.ts +237 -0
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
2
5
|
|
|
3
|
-
import { runLocalAgentTurn } from "
|
|
6
|
+
import { runLocalAgentTurn } from "../agent-runtime/localLoop";
|
|
4
7
|
import { createCliLocalRuntimeAdapter } from "./localRuntimeAdapter";
|
|
5
8
|
|
|
6
9
|
describe("CLI local runtime adapter", () => {
|
|
10
|
+
const DEFAULT_LOCAL_CODING_TOOL_NAMES = [
|
|
11
|
+
"readWorkspaceFile",
|
|
12
|
+
"writeWorkspaceFile",
|
|
13
|
+
"replaceWorkspaceText",
|
|
14
|
+
"searchWorkspace",
|
|
15
|
+
"applyPatch",
|
|
16
|
+
"gitStatus",
|
|
17
|
+
"gitDiff",
|
|
18
|
+
"gitCreateBranch",
|
|
19
|
+
"commitWorkspace",
|
|
20
|
+
"runPackageScript",
|
|
21
|
+
"startPreview",
|
|
22
|
+
"getPreviewStatus",
|
|
23
|
+
"stopPreview",
|
|
24
|
+
"releasePreview",
|
|
25
|
+
"captureVisualState",
|
|
26
|
+
];
|
|
27
|
+
const SHELL_LOCAL_CODING_TOOL_NAMES = [
|
|
28
|
+
...DEFAULT_LOCAL_CODING_TOOL_NAMES,
|
|
29
|
+
"execShell",
|
|
30
|
+
"execBash",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function toolNamesFromRequest(request: any) {
|
|
34
|
+
return request?.body?.tools?.map((tool: any) => tool.function.name) ?? [];
|
|
35
|
+
}
|
|
36
|
+
|
|
7
37
|
test("loads agent/history from LevelDB and saves dialog/message records back to LevelDB", async () => {
|
|
8
38
|
const requests: Array<{ url: string; body: any; auth: string | null }> = [];
|
|
9
39
|
const store = new Map<string, any>([
|
|
@@ -81,9 +111,9 @@ describe("CLI local runtime adapter", () => {
|
|
|
81
111
|
expect(result).toMatchObject({
|
|
82
112
|
content: "local adapter ok",
|
|
83
113
|
model: "gpt-4.1-mini",
|
|
84
|
-
dialogId: "
|
|
114
|
+
dialogId: "dialog-existing",
|
|
85
115
|
});
|
|
86
|
-
expect(requests).
|
|
116
|
+
expect(requests[0]).toMatchObject({
|
|
87
117
|
url: "http://127.0.0.1:11434/v1/chat/completions",
|
|
88
118
|
auth: "Bearer sk-local",
|
|
89
119
|
body: {
|
|
@@ -95,31 +125,679 @@ describe("CLI local runtime adapter", () => {
|
|
|
95
125
|
],
|
|
96
126
|
stream: false,
|
|
97
127
|
},
|
|
98
|
-
}
|
|
128
|
+
});
|
|
129
|
+
expect(toolNamesFromRequest(requests[0])).toEqual(DEFAULT_LOCAL_CODING_TOOL_NAMES);
|
|
99
130
|
expect(batchOps.map((op) => op.key)).toEqual([
|
|
100
|
-
"dialog-user-1-
|
|
101
|
-
"dialog-
|
|
102
|
-
"dialog-
|
|
131
|
+
"dialog-user-1-dialog-existing",
|
|
132
|
+
"dialog-dialog-existing-msg-1710000000000-001",
|
|
133
|
+
"dialog-dialog-existing-msg-1710000000000-002",
|
|
103
134
|
]);
|
|
104
|
-
expect(store.get("dialog-user-1-
|
|
105
|
-
id: "
|
|
106
|
-
dbKey: "dialog-user-1-
|
|
135
|
+
expect(store.get("dialog-user-1-dialog-existing")).toMatchObject({
|
|
136
|
+
id: "dialog-existing",
|
|
137
|
+
dbKey: "dialog-user-1-dialog-existing",
|
|
107
138
|
type: "dialog",
|
|
108
139
|
primaryAgentKey: "agent-user-1-frontend",
|
|
109
140
|
status: "done",
|
|
110
141
|
});
|
|
111
|
-
expect(store.get("dialog-
|
|
112
|
-
dialogId: "
|
|
142
|
+
expect(store.get("dialog-dialog-existing-msg-1710000000000-001")).toMatchObject({
|
|
143
|
+
dialogId: "dialog-existing",
|
|
113
144
|
role: "user",
|
|
114
145
|
content: "make it cleaner",
|
|
115
146
|
});
|
|
116
|
-
expect(store.get("dialog-
|
|
117
|
-
dialogId: "
|
|
147
|
+
expect(store.get("dialog-dialog-existing-msg-1710000000000-002")).toMatchObject({
|
|
148
|
+
dialogId: "dialog-existing",
|
|
118
149
|
role: "assistant",
|
|
119
150
|
content: "local adapter ok",
|
|
120
151
|
});
|
|
121
152
|
});
|
|
122
153
|
|
|
154
|
+
test("loads a missing explicit agent key through the hybrid store remote cache", async () => {
|
|
155
|
+
const memory = new Map<string, any>();
|
|
156
|
+
const requests: string[] = [];
|
|
157
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
158
|
+
env: {
|
|
159
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
160
|
+
NOLO_SERVER: "https://us.nolo.chat",
|
|
161
|
+
AUTH_TOKEN: "token-1",
|
|
162
|
+
OPENAI_API_KEY: "sk-local",
|
|
163
|
+
NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
|
|
164
|
+
},
|
|
165
|
+
db: {
|
|
166
|
+
get: async (key) => {
|
|
167
|
+
if (!memory.has(key)) throw new Error(`not found: ${key}`);
|
|
168
|
+
return memory.get(key);
|
|
169
|
+
},
|
|
170
|
+
put: async (key, value) => {
|
|
171
|
+
memory.set(key, value);
|
|
172
|
+
},
|
|
173
|
+
batch: async (ops) => {
|
|
174
|
+
for (const op of ops) {
|
|
175
|
+
if (op.type === "put") memory.set(op.key, op.value);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
iterator: () => (async function* () {})(),
|
|
179
|
+
},
|
|
180
|
+
createId: () => "01REMOTE",
|
|
181
|
+
fetchImpl: async (url, init) => {
|
|
182
|
+
requests.push(String(url));
|
|
183
|
+
if (String(url).includes("/api/v1/db/read/")) {
|
|
184
|
+
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer token-1");
|
|
185
|
+
return Response.json({
|
|
186
|
+
data: {
|
|
187
|
+
dbKey: "agent-user-1-remote",
|
|
188
|
+
name: "Remote cached",
|
|
189
|
+
prompt: "Remote prompt",
|
|
190
|
+
model: "gpt-4.1-mini",
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return Response.json({
|
|
195
|
+
choices: [{ message: { content: "remote cache ok" } }],
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const result = await runLocalAgentTurn({
|
|
201
|
+
adapter,
|
|
202
|
+
agentRef: "agent-user-1-remote",
|
|
203
|
+
input: "hello",
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(result.content).toBe("remote cache ok");
|
|
207
|
+
expect(requests[0]).toBe("https://us.nolo.chat/api/v1/db/read/agent-user-1-remote");
|
|
208
|
+
expect(memory.get("agent-user-1-remote")).toMatchObject({
|
|
209
|
+
name: "Remote cached",
|
|
210
|
+
serverOrigin: "https://us.nolo.chat",
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("uses agent customProviderUrl for local OpenAI-compatible requests", async () => {
|
|
215
|
+
const requests: Array<{ url: string; auth: string | null; body: any }> = [];
|
|
216
|
+
const store = new Map<string, any>([
|
|
217
|
+
["agent-user-1-custom", {
|
|
218
|
+
dbKey: "agent-user-1-custom",
|
|
219
|
+
id: "custom",
|
|
220
|
+
prompt: "Use custom provider.",
|
|
221
|
+
model: "custom-coder",
|
|
222
|
+
provider: "custom-openai-compatible",
|
|
223
|
+
apiSource: "custom",
|
|
224
|
+
customProviderUrl: "https://provider.example/v1/chat/completions",
|
|
225
|
+
temperature: 0.2,
|
|
226
|
+
max_tokens: 4096,
|
|
227
|
+
reasoning_effort: "medium",
|
|
228
|
+
}],
|
|
229
|
+
]);
|
|
230
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
231
|
+
env: {
|
|
232
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
233
|
+
OPENAI_API_KEY: "sk-custom",
|
|
234
|
+
NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
|
|
235
|
+
},
|
|
236
|
+
db: {
|
|
237
|
+
get: async (key) => {
|
|
238
|
+
if (!store.has(key)) throw new Error(`not found: ${key}`);
|
|
239
|
+
return store.get(key);
|
|
240
|
+
},
|
|
241
|
+
put: async (key, value) => {
|
|
242
|
+
store.set(key, value);
|
|
243
|
+
},
|
|
244
|
+
batch: async (ops) => {
|
|
245
|
+
for (const op of ops) {
|
|
246
|
+
if (op.type === "put") store.set(op.key, op.value);
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
iterator: () => (async function* () {})(),
|
|
250
|
+
},
|
|
251
|
+
fetchImpl: async (url, init) => {
|
|
252
|
+
requests.push({
|
|
253
|
+
url: String(url),
|
|
254
|
+
auth: new Headers(init?.headers).get("Authorization"),
|
|
255
|
+
body: JSON.parse(String(init?.body)),
|
|
256
|
+
});
|
|
257
|
+
return Response.json({
|
|
258
|
+
choices: [{ message: { content: "custom ok" } }],
|
|
259
|
+
});
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const result = await runLocalAgentTurn({
|
|
264
|
+
adapter,
|
|
265
|
+
agentRef: "custom",
|
|
266
|
+
input: "hello",
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(result.content).toBe("custom ok");
|
|
270
|
+
expect(result.provider).toBe("custom-openai-compatible");
|
|
271
|
+
expect(requests[0]).toMatchObject({
|
|
272
|
+
url: "https://provider.example/v1/chat/completions",
|
|
273
|
+
auth: "Bearer sk-custom",
|
|
274
|
+
body: {
|
|
275
|
+
model: "custom-coder",
|
|
276
|
+
messages: [
|
|
277
|
+
{ role: "system", content: "Use custom provider." },
|
|
278
|
+
{ role: "user", content: "hello" },
|
|
279
|
+
],
|
|
280
|
+
stream: false,
|
|
281
|
+
temperature: 0.2,
|
|
282
|
+
max_tokens: 4096,
|
|
283
|
+
reasoning_effort: "medium",
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
expect(toolNamesFromRequest(requests[0])).toEqual(DEFAULT_LOCAL_CODING_TOOL_NAMES);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("uses the Nolo chat proxy when local provider keys are absent", async () => {
|
|
290
|
+
const requests: Array<{ url: string; auth: string | null; body: any }> = [];
|
|
291
|
+
const store = new Map<string, any>([
|
|
292
|
+
["agent-user-1-frontend", {
|
|
293
|
+
dbKey: "agent-user-1-frontend",
|
|
294
|
+
id: "frontend",
|
|
295
|
+
prompt: "Fix UI.",
|
|
296
|
+
model: "accounts/fireworks/models/kimi-k2p6",
|
|
297
|
+
provider: "fireworks",
|
|
298
|
+
tools: ["writeWorkspaceFile"],
|
|
299
|
+
}],
|
|
300
|
+
]);
|
|
301
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
302
|
+
env: {
|
|
303
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
304
|
+
NOLO_SERVER: "https://us.nolo.chat",
|
|
305
|
+
AUTH_TOKEN: "token-1",
|
|
306
|
+
},
|
|
307
|
+
db: {
|
|
308
|
+
get: async (key) => {
|
|
309
|
+
if (!store.has(key)) throw new Error(`not found: ${key}`);
|
|
310
|
+
return store.get(key);
|
|
311
|
+
},
|
|
312
|
+
put: async (key, value) => {
|
|
313
|
+
store.set(key, value);
|
|
314
|
+
},
|
|
315
|
+
batch: async (ops) => {
|
|
316
|
+
for (const op of ops) {
|
|
317
|
+
if (op.type === "put") store.set(op.key, op.value);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
iterator: () => (async function* () {})(),
|
|
321
|
+
},
|
|
322
|
+
fetchImpl: async (url, init) => {
|
|
323
|
+
requests.push({
|
|
324
|
+
url: String(url),
|
|
325
|
+
auth: new Headers(init?.headers).get("Authorization"),
|
|
326
|
+
body: JSON.parse(String(init?.body)),
|
|
327
|
+
});
|
|
328
|
+
return Response.json({
|
|
329
|
+
choices: [{ message: { content: "platform ok" } }],
|
|
330
|
+
usage: { prompt_tokens: 7, completion_tokens: 2 },
|
|
331
|
+
});
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const result = await runLocalAgentTurn({
|
|
336
|
+
adapter,
|
|
337
|
+
agentRef: "frontend",
|
|
338
|
+
input: "make notifications cleaner",
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
expect(result.content).toBe("platform ok");
|
|
342
|
+
expect(result.provider).toBe("fireworks");
|
|
343
|
+
expect(requests[0]).toMatchObject({
|
|
344
|
+
url: "https://us.nolo.chat/api/v1/chat",
|
|
345
|
+
auth: "Bearer token-1",
|
|
346
|
+
body: {
|
|
347
|
+
model: "accounts/fireworks/models/kimi-k2p6",
|
|
348
|
+
messages: [
|
|
349
|
+
{ role: "system", content: "Fix UI." },
|
|
350
|
+
{ role: "user", content: "make notifications cleaner" },
|
|
351
|
+
],
|
|
352
|
+
stream: false,
|
|
353
|
+
tool_choice: "auto",
|
|
354
|
+
url: "https://api.fireworks.ai/inference/v1/chat/completions",
|
|
355
|
+
provider: "fireworks",
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
expect(toolNamesFromRequest(requests[0])).toEqual(DEFAULT_LOCAL_CODING_TOOL_NAMES);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("uses the Nolo chat proxy for platform agents even when direct provider env exists", async () => {
|
|
362
|
+
const requests: Array<{ url: string; auth: string | null; body: any }> = [];
|
|
363
|
+
const store = new Map<string, any>([
|
|
364
|
+
["agent-user-1-frontend", {
|
|
365
|
+
dbKey: "agent-user-1-frontend",
|
|
366
|
+
id: "frontend",
|
|
367
|
+
prompt: "Fix UI.",
|
|
368
|
+
model: "accounts/fireworks/models/kimi-k2p6",
|
|
369
|
+
provider: "fireworks",
|
|
370
|
+
apiSource: "platform",
|
|
371
|
+
useServerProxy: true,
|
|
372
|
+
tools: ["gitStatus"],
|
|
373
|
+
}],
|
|
374
|
+
]);
|
|
375
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
376
|
+
env: {
|
|
377
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
378
|
+
NOLO_SERVER: "https://us.nolo.chat",
|
|
379
|
+
AUTH_TOKEN: "token-1",
|
|
380
|
+
OPENAI_API_KEY: "sk-direct",
|
|
381
|
+
},
|
|
382
|
+
db: {
|
|
383
|
+
get: async (key) => {
|
|
384
|
+
if (!store.has(key)) throw new Error(`not found: ${key}`);
|
|
385
|
+
return store.get(key);
|
|
386
|
+
},
|
|
387
|
+
put: async (key, value) => {
|
|
388
|
+
store.set(key, value);
|
|
389
|
+
},
|
|
390
|
+
batch: async (ops) => {
|
|
391
|
+
for (const op of ops) {
|
|
392
|
+
if (op.type === "put") store.set(op.key, op.value);
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
iterator: () => (async function* () {})(),
|
|
396
|
+
},
|
|
397
|
+
fetchImpl: async (url, init) => {
|
|
398
|
+
requests.push({
|
|
399
|
+
url: String(url),
|
|
400
|
+
auth: new Headers(init?.headers).get("Authorization"),
|
|
401
|
+
body: JSON.parse(String(init?.body)),
|
|
402
|
+
});
|
|
403
|
+
return Response.json({
|
|
404
|
+
choices: [{ message: { content: "platform ok" } }],
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const result = await runLocalAgentTurn({
|
|
410
|
+
adapter,
|
|
411
|
+
agentRef: "frontend",
|
|
412
|
+
input: "inspect",
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
expect(result.content).toBe("platform ok");
|
|
416
|
+
expect(requests[0]).toMatchObject({
|
|
417
|
+
url: "https://us.nolo.chat/api/v1/chat",
|
|
418
|
+
auth: "Bearer token-1",
|
|
419
|
+
body: {
|
|
420
|
+
provider: "fireworks",
|
|
421
|
+
apiSource: "platform",
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("retries transient certificate failures from the platform chat proxy", async () => {
|
|
427
|
+
let attempts = 0;
|
|
428
|
+
const store = new Map<string, any>([
|
|
429
|
+
["agent-user-1-frontend", {
|
|
430
|
+
dbKey: "agent-user-1-frontend",
|
|
431
|
+
id: "frontend",
|
|
432
|
+
prompt: "Fix UI.",
|
|
433
|
+
model: "accounts/fireworks/models/kimi-k2p6",
|
|
434
|
+
provider: "fireworks",
|
|
435
|
+
apiSource: "platform",
|
|
436
|
+
useServerProxy: true,
|
|
437
|
+
}],
|
|
438
|
+
]);
|
|
439
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
440
|
+
env: {
|
|
441
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
442
|
+
NOLO_SERVER: "https://us.nolo.chat",
|
|
443
|
+
AUTH_TOKEN: "token-1",
|
|
444
|
+
},
|
|
445
|
+
db: {
|
|
446
|
+
get: async (key) => {
|
|
447
|
+
if (!store.has(key)) throw new Error(`not found: ${key}`);
|
|
448
|
+
return store.get(key);
|
|
449
|
+
},
|
|
450
|
+
put: async (key, value) => {
|
|
451
|
+
store.set(key, value);
|
|
452
|
+
},
|
|
453
|
+
batch: async (ops) => {
|
|
454
|
+
for (const op of ops) {
|
|
455
|
+
if (op.type === "put") store.set(op.key, op.value);
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
iterator: () => (async function* () {})(),
|
|
459
|
+
},
|
|
460
|
+
fetchImpl: async () => {
|
|
461
|
+
attempts += 1;
|
|
462
|
+
if (attempts <= 2) {
|
|
463
|
+
throw new Error("unknown certificate verification error");
|
|
464
|
+
}
|
|
465
|
+
return Response.json({
|
|
466
|
+
choices: [{ message: { content: "platform retry ok" } }],
|
|
467
|
+
});
|
|
468
|
+
},
|
|
469
|
+
sleep: async () => {},
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const result = await runLocalAgentTurn({
|
|
473
|
+
adapter,
|
|
474
|
+
agentRef: "frontend",
|
|
475
|
+
input: "inspect",
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
expect(result.content).toBe("platform retry ok");
|
|
479
|
+
expect(attempts).toBe(3);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("keeps retrying repeated transient certificate failures with backoff hooks", async () => {
|
|
483
|
+
let attempts = 0;
|
|
484
|
+
const retryDelays: number[] = [];
|
|
485
|
+
const store = new Map<string, any>([
|
|
486
|
+
["agent-user-1-frontend", {
|
|
487
|
+
dbKey: "agent-user-1-frontend",
|
|
488
|
+
id: "frontend",
|
|
489
|
+
prompt: "Fix UI.",
|
|
490
|
+
model: "accounts/fireworks/models/kimi-k2p6",
|
|
491
|
+
provider: "fireworks",
|
|
492
|
+
apiSource: "platform",
|
|
493
|
+
useServerProxy: true,
|
|
494
|
+
}],
|
|
495
|
+
]);
|
|
496
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
497
|
+
env: {
|
|
498
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
499
|
+
NOLO_SERVER: "https://us.nolo.chat",
|
|
500
|
+
AUTH_TOKEN: "token-1",
|
|
501
|
+
},
|
|
502
|
+
db: {
|
|
503
|
+
get: async (key) => {
|
|
504
|
+
if (!store.has(key)) throw new Error(`not found: ${key}`);
|
|
505
|
+
return store.get(key);
|
|
506
|
+
},
|
|
507
|
+
put: async (key, value) => {
|
|
508
|
+
store.set(key, value);
|
|
509
|
+
},
|
|
510
|
+
batch: async (ops) => {
|
|
511
|
+
for (const op of ops) {
|
|
512
|
+
if (op.type === "put") store.set(op.key, op.value);
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
iterator: () => (async function* () {})(),
|
|
516
|
+
},
|
|
517
|
+
fetchImpl: async () => {
|
|
518
|
+
attempts += 1;
|
|
519
|
+
if (attempts <= 5) {
|
|
520
|
+
throw new Error("unknown certificate verification error");
|
|
521
|
+
}
|
|
522
|
+
return Response.json({
|
|
523
|
+
choices: [{ message: { content: "platform longer retry ok" } }],
|
|
524
|
+
});
|
|
525
|
+
},
|
|
526
|
+
sleep: async (ms) => {
|
|
527
|
+
retryDelays.push(ms);
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const result = await runLocalAgentTurn({
|
|
532
|
+
adapter,
|
|
533
|
+
agentRef: "frontend",
|
|
534
|
+
input: "inspect",
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
expect(result.content).toBe("platform longer retry ok");
|
|
538
|
+
expect(attempts).toBe(6);
|
|
539
|
+
expect(retryDelays.length).toBe(5);
|
|
540
|
+
expect(retryDelays[0]).toBeGreaterThan(0);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("saves local tool call trace and shell metadata into the local dialog", async () => {
|
|
544
|
+
const store = new Map<string, any>([
|
|
545
|
+
["agent-user-1-shell", {
|
|
546
|
+
dbKey: "agent-user-1-shell",
|
|
547
|
+
id: "shell",
|
|
548
|
+
prompt: "Use shell.",
|
|
549
|
+
model: "gpt-4.1-mini",
|
|
550
|
+
}],
|
|
551
|
+
]);
|
|
552
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
553
|
+
env: {
|
|
554
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
555
|
+
NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
|
|
556
|
+
NOLO_LOCAL_SHELL_MODE: "worktree",
|
|
557
|
+
},
|
|
558
|
+
db: {
|
|
559
|
+
get: async (key) => {
|
|
560
|
+
if (!store.has(key)) throw new Error(`not found: ${key}`);
|
|
561
|
+
return store.get(key);
|
|
562
|
+
},
|
|
563
|
+
put: async (key, value) => {
|
|
564
|
+
store.set(key, value);
|
|
565
|
+
},
|
|
566
|
+
batch: async (ops) => {
|
|
567
|
+
for (const op of ops) {
|
|
568
|
+
if (op.type === "put") store.set(op.key, op.value);
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
iterator: () => (async function* () {})(),
|
|
572
|
+
},
|
|
573
|
+
cwd: import.meta.dir,
|
|
574
|
+
now: () => 1710000000000,
|
|
575
|
+
createId: () => "01TRACE",
|
|
576
|
+
fetchImpl: async (_url, init) => {
|
|
577
|
+
const body = JSON.parse(String(init?.body));
|
|
578
|
+
const hasToolResult = body.messages.some((message: any) => message.role === "tool");
|
|
579
|
+
if (!hasToolResult) {
|
|
580
|
+
return Response.json({
|
|
581
|
+
choices: [{
|
|
582
|
+
message: {
|
|
583
|
+
content: "",
|
|
584
|
+
tool_calls: [{
|
|
585
|
+
id: "call-shell",
|
|
586
|
+
type: "function",
|
|
587
|
+
function: {
|
|
588
|
+
name: "execShell",
|
|
589
|
+
arguments: JSON.stringify({ cmd: "printf trace-ok" }),
|
|
590
|
+
},
|
|
591
|
+
}],
|
|
592
|
+
},
|
|
593
|
+
}],
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
return Response.json({
|
|
597
|
+
choices: [{ message: { content: "done" } }],
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const result = await runLocalAgentTurn({
|
|
603
|
+
adapter,
|
|
604
|
+
agentRef: "shell",
|
|
605
|
+
input: "inspect",
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
expect(result.dialogId).toBe("01TRACE");
|
|
609
|
+
expect(store.get("dialog-user-1-01TRACE")).toMatchObject({
|
|
610
|
+
toolCallCount: 1,
|
|
611
|
+
localRuntime: expect.objectContaining({
|
|
612
|
+
host: "cli",
|
|
613
|
+
worktreePath: import.meta.dir,
|
|
614
|
+
}),
|
|
615
|
+
});
|
|
616
|
+
const messages = [...store.entries()]
|
|
617
|
+
.filter(([key]) => key.startsWith("dialog-01TRACE-msg-"))
|
|
618
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
619
|
+
.map(([, value]) => value);
|
|
620
|
+
expect(messages.map((message) => message.role)).toEqual([
|
|
621
|
+
"user",
|
|
622
|
+
"assistant",
|
|
623
|
+
"tool",
|
|
624
|
+
"assistant",
|
|
625
|
+
]);
|
|
626
|
+
expect(messages[1]).toMatchObject({
|
|
627
|
+
tool_calls: [{
|
|
628
|
+
id: "call-shell",
|
|
629
|
+
function: { name: "execShell" },
|
|
630
|
+
}],
|
|
631
|
+
});
|
|
632
|
+
expect(messages[2]).toMatchObject({
|
|
633
|
+
role: "tool",
|
|
634
|
+
toolCallId: "call-shell",
|
|
635
|
+
metadata: { exitCode: 0 },
|
|
636
|
+
});
|
|
637
|
+
expect(messages[2].content).toContain("trace-ok");
|
|
638
|
+
expect(messages[3]).toMatchObject({
|
|
639
|
+
role: "assistant",
|
|
640
|
+
content: "done",
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("returns tool budget errors to the local loop", async () => {
|
|
645
|
+
const store = new Map<string, any>([
|
|
646
|
+
["agent-user-1-reader", {
|
|
647
|
+
dbKey: "agent-user-1-reader",
|
|
648
|
+
id: "reader",
|
|
649
|
+
prompt: "Read narrowly",
|
|
650
|
+
model: "gpt-4.1-mini",
|
|
651
|
+
provider: "openai-compatible",
|
|
652
|
+
tools: ["readWorkspaceFile"],
|
|
653
|
+
}],
|
|
654
|
+
]);
|
|
655
|
+
let requestCount = 0;
|
|
656
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
657
|
+
env: {
|
|
658
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
659
|
+
NOLO_LOCAL_OPENAI_BASE_URL: "https://llm.example/v1",
|
|
660
|
+
NOLO_LOCAL_OPENAI_API_KEY: "sk-test",
|
|
661
|
+
NOLO_LOCAL_TOOL_BUDGETS: "readWorkspaceFile=1",
|
|
662
|
+
},
|
|
663
|
+
store: {
|
|
664
|
+
read: async (key) => {
|
|
665
|
+
if (!store.has(key)) throw new Error(`not found: ${key}`);
|
|
666
|
+
return store.get(key);
|
|
667
|
+
},
|
|
668
|
+
put: async (key, value) => {
|
|
669
|
+
store.set(key, value);
|
|
670
|
+
},
|
|
671
|
+
batch: async (ops) => {
|
|
672
|
+
for (const op of ops) {
|
|
673
|
+
if (op.type === "put") store.set(op.key, op.value);
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
iterator: () => (async function* () {})(),
|
|
677
|
+
},
|
|
678
|
+
cwd: import.meta.dir,
|
|
679
|
+
now: () => 1710000000000,
|
|
680
|
+
createId: () => "01BUDGET",
|
|
681
|
+
localToolExecutors: {
|
|
682
|
+
readWorkspaceFile: async () => ({ content: "file content" }),
|
|
683
|
+
},
|
|
684
|
+
fetchImpl: async (_url, init) => {
|
|
685
|
+
requestCount += 1;
|
|
686
|
+
const body = JSON.parse(String(init?.body));
|
|
687
|
+
const toolMessages = body.messages.filter((message: any) => message.role === "tool");
|
|
688
|
+
if (toolMessages.some((message: any) => String(message.content).includes("exceeded local tool budget"))) {
|
|
689
|
+
return Response.json({ choices: [{ message: { content: "budget handled" } }] });
|
|
690
|
+
}
|
|
691
|
+
return Response.json({
|
|
692
|
+
choices: [{
|
|
693
|
+
message: {
|
|
694
|
+
content: "",
|
|
695
|
+
tool_calls: [{
|
|
696
|
+
id: `call-read-${requestCount}`,
|
|
697
|
+
type: "function",
|
|
698
|
+
function: {
|
|
699
|
+
name: "readWorkspaceFile",
|
|
700
|
+
arguments: JSON.stringify({ path: "file.ts" }),
|
|
701
|
+
},
|
|
702
|
+
}],
|
|
703
|
+
},
|
|
704
|
+
}],
|
|
705
|
+
});
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
const result = await runLocalAgentTurn({
|
|
710
|
+
adapter,
|
|
711
|
+
agentRef: "agent-user-1-reader",
|
|
712
|
+
input: "inspect",
|
|
713
|
+
maxToolRounds: 3,
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
expect(result.content).toBe("budget handled");
|
|
717
|
+
const messages = [...store.values()];
|
|
718
|
+
expect(messages.some((message) => String(message.content).includes("exceeded local tool budget 1"))).toBe(true);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("continues a local dialog instead of creating a new one", async () => {
|
|
722
|
+
const store = new Map<string, any>([
|
|
723
|
+
["agent-user-1-frontend", {
|
|
724
|
+
dbKey: "agent-user-1-frontend",
|
|
725
|
+
id: "frontend",
|
|
726
|
+
prompt: "Fix UI",
|
|
727
|
+
model: "gpt-4.1-mini",
|
|
728
|
+
}],
|
|
729
|
+
["dialog-user-1-dialog-existing", {
|
|
730
|
+
dbKey: "dialog-user-1-dialog-existing",
|
|
731
|
+
id: "dialog-existing",
|
|
732
|
+
type: "dialog",
|
|
733
|
+
userId: "user-1",
|
|
734
|
+
title: "Existing dialog",
|
|
735
|
+
}],
|
|
736
|
+
["dialog-dialog-existing-msg-001", {
|
|
737
|
+
dbKey: "dialog-dialog-existing-msg-001",
|
|
738
|
+
id: "msg-001",
|
|
739
|
+
dialogId: "dialog-existing",
|
|
740
|
+
role: "assistant",
|
|
741
|
+
content: "previous answer",
|
|
742
|
+
}],
|
|
743
|
+
]);
|
|
744
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
745
|
+
env: {
|
|
746
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
747
|
+
NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
|
|
748
|
+
},
|
|
749
|
+
db: {
|
|
750
|
+
get: async (key) => {
|
|
751
|
+
if (!store.has(key)) throw new Error(`not found: ${key}`);
|
|
752
|
+
return store.get(key);
|
|
753
|
+
},
|
|
754
|
+
put: async (key, value) => {
|
|
755
|
+
store.set(key, value);
|
|
756
|
+
},
|
|
757
|
+
batch: async (ops) => {
|
|
758
|
+
for (const op of ops) {
|
|
759
|
+
if (op.type === "put") store.set(op.key, op.value);
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
iterator: ({ gte, lte }) => (async function* () {
|
|
763
|
+
for (const entry of [...store.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
764
|
+
if (entry[0] >= gte && entry[0] <= lte) yield entry;
|
|
765
|
+
}
|
|
766
|
+
})(),
|
|
767
|
+
},
|
|
768
|
+
now: () => 1710000000000,
|
|
769
|
+
createId: () => "SHOULDNOTUSE",
|
|
770
|
+
fetchImpl: async (_url, init) => {
|
|
771
|
+
const body = JSON.parse(String(init?.body));
|
|
772
|
+
expect(body.messages).toContainEqual({
|
|
773
|
+
role: "assistant",
|
|
774
|
+
content: "previous answer",
|
|
775
|
+
});
|
|
776
|
+
return Response.json({
|
|
777
|
+
choices: [{ message: { content: "continued" } }],
|
|
778
|
+
});
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
const result = await runLocalAgentTurn({
|
|
783
|
+
adapter,
|
|
784
|
+
agentRef: "frontend",
|
|
785
|
+
input: "continue",
|
|
786
|
+
continueDialogId: "dialog-existing",
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
expect(result.dialogId).toBe("dialog-existing");
|
|
790
|
+
expect(store.has("dialog-user-1-SHOULDNOTUSE")).toBe(false);
|
|
791
|
+
expect(store.get("dialog-user-1-dialog-existing")).toMatchObject({
|
|
792
|
+
id: "dialog-existing",
|
|
793
|
+
title: "Existing dialog",
|
|
794
|
+
status: "done",
|
|
795
|
+
});
|
|
796
|
+
expect([...store.keys()].filter((key) => key.startsWith("dialog-dialog-existing-msg-"))).toContain(
|
|
797
|
+
"dialog-dialog-existing-msg-1710000000000-001"
|
|
798
|
+
);
|
|
799
|
+
});
|
|
800
|
+
|
|
123
801
|
test("passes image_url message parts through to OpenAI-compatible providers", async () => {
|
|
124
802
|
const requests: Array<{ body: any }> = [];
|
|
125
803
|
const adapter = createCliLocalRuntimeAdapter({
|
|
@@ -247,12 +925,83 @@ describe("CLI local runtime adapter", () => {
|
|
|
247
925
|
input: "pwd",
|
|
248
926
|
});
|
|
249
927
|
|
|
250
|
-
expect(requests[0]
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
928
|
+
expect(toolNamesFromRequest(requests[0])).toEqual(SHELL_LOCAL_CODING_TOOL_NAMES);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
test("allows default semantic workspace tools without legacy agent tool declarations", async () => {
|
|
932
|
+
const workspaceRoot = mkdtempSync(join(tmpdir(), "nolo-cli-runtime-"));
|
|
933
|
+
try {
|
|
934
|
+
await Bun.write(join(workspaceRoot, "README.md"), "local ok\n");
|
|
935
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
936
|
+
env: {},
|
|
937
|
+
cwd: workspaceRoot,
|
|
938
|
+
db: {
|
|
939
|
+
get: async () => ({
|
|
940
|
+
dbKey: "agent-local-default-tools",
|
|
941
|
+
prompt: "Use local workspace tools.",
|
|
942
|
+
model: "gpt-4.1-mini",
|
|
943
|
+
}),
|
|
944
|
+
put: async () => {},
|
|
945
|
+
batch: async () => {},
|
|
946
|
+
iterator: () => (async function* () {})(),
|
|
947
|
+
},
|
|
948
|
+
fetchImpl: async () => Response.json({}),
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
await adapter.loadAgentConfig("default-tools");
|
|
952
|
+
await expect(adapter.executeTool({
|
|
953
|
+
id: "call-read",
|
|
954
|
+
name: "readWorkspaceFile",
|
|
955
|
+
arguments: JSON.stringify({ path: "README.md" }),
|
|
956
|
+
})).resolves.toMatchObject({
|
|
957
|
+
content: "local ok\n",
|
|
958
|
+
});
|
|
959
|
+
} finally {
|
|
960
|
+
rmSync(workspaceRoot, { recursive: true, force: true });
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test("activates an agent-declared task worktree before running workspace tools", async () => {
|
|
965
|
+
const taskWorktreeRoot = mkdtempSync(join(tmpdir(), "nolo-cli-runtime-worktree-"));
|
|
966
|
+
const chunks: string[] = [];
|
|
967
|
+
try {
|
|
968
|
+
await Bun.write(join(taskWorktreeRoot, "README.md"), "worktree ok\n");
|
|
969
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
970
|
+
env: {
|
|
971
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
972
|
+
},
|
|
973
|
+
output: { write: (chunk) => chunks.push(chunk) },
|
|
974
|
+
db: {
|
|
975
|
+
get: async () => ({
|
|
976
|
+
dbKey: "agent-user-1-frontend",
|
|
977
|
+
id: "frontend",
|
|
978
|
+
prompt: "Use local workspace tools.",
|
|
979
|
+
runtimeBinding: { localWorkspaceMode: "task-worktree" },
|
|
980
|
+
}),
|
|
981
|
+
put: async () => {},
|
|
982
|
+
batch: async () => {},
|
|
983
|
+
iterator: () => (async function* () {})(),
|
|
984
|
+
},
|
|
985
|
+
prepareTaskWorktree: async ({ agentKey }) => ({
|
|
986
|
+
path: taskWorktreeRoot,
|
|
987
|
+
branchName: `nolo-agent-${agentKey}`,
|
|
988
|
+
}),
|
|
989
|
+
fetchImpl: async () => Response.json({}),
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
await adapter.loadAgentConfig("frontend");
|
|
993
|
+
const result = await adapter.executeTool({
|
|
994
|
+
id: "call-read",
|
|
995
|
+
name: "readWorkspaceFile",
|
|
996
|
+
arguments: JSON.stringify({ path: "README.md" }),
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
expect(result.content).toBe("worktree ok\n");
|
|
1000
|
+
expect(chunks.join("")).toContain(`workspace session: task-worktree ${taskWorktreeRoot}`);
|
|
1001
|
+
expect(chunks.join("")).toContain("branch nolo-agent-frontend");
|
|
1002
|
+
} finally {
|
|
1003
|
+
rmSync(taskWorktreeRoot, { recursive: true, force: true });
|
|
1004
|
+
}
|
|
256
1005
|
});
|
|
257
1006
|
|
|
258
1007
|
test("runs execShell locally in explicit worktree shell mode", async () => {
|
|
@@ -281,4 +1030,48 @@ describe("CLI local runtime adapter", () => {
|
|
|
281
1030
|
expect(result.content).toContain(import.meta.dir);
|
|
282
1031
|
expect(result.metadata).toMatchObject({ exitCode: 0 });
|
|
283
1032
|
});
|
|
1033
|
+
|
|
1034
|
+
test("runs allowed workspace file tools through default local executors", async () => {
|
|
1035
|
+
const workspaceRoot = mkdtempSync(join(tmpdir(), "nolo-cli-runtime-"));
|
|
1036
|
+
try {
|
|
1037
|
+
const store = new Map<string, any>([
|
|
1038
|
+
["agent-user-1-writer", {
|
|
1039
|
+
dbKey: "agent-user-1-writer",
|
|
1040
|
+
id: "writer",
|
|
1041
|
+
toolNames: ["writeWorkspaceFile"],
|
|
1042
|
+
}],
|
|
1043
|
+
]);
|
|
1044
|
+
const adapter = createCliLocalRuntimeAdapter({
|
|
1045
|
+
env: {
|
|
1046
|
+
NOLO_LOCAL_USER_ID: "user-1",
|
|
1047
|
+
},
|
|
1048
|
+
db: {
|
|
1049
|
+
get: async (key) => {
|
|
1050
|
+
if (!store.has(key)) throw new Error(`not found: ${key}`);
|
|
1051
|
+
return store.get(key);
|
|
1052
|
+
},
|
|
1053
|
+
put: async () => {},
|
|
1054
|
+
batch: async () => {},
|
|
1055
|
+
iterator: () => (async function* () {})(),
|
|
1056
|
+
},
|
|
1057
|
+
cwd: workspaceRoot,
|
|
1058
|
+
fetchImpl: async () => Response.json({}),
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
await adapter.loadAgentConfig("writer");
|
|
1062
|
+
const result = await adapter.executeTool({
|
|
1063
|
+
id: "call-1",
|
|
1064
|
+
name: "writeWorkspaceFile",
|
|
1065
|
+
arguments: JSON.stringify({
|
|
1066
|
+
path: "src/app.ts",
|
|
1067
|
+
content: "export const cliValue = 1;\n",
|
|
1068
|
+
}),
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
expect(result.content).toBe("wrote src/app.ts");
|
|
1072
|
+
expect(readFileSync(join(workspaceRoot, "src/app.ts"), "utf8")).toBe("export const cliValue = 1;\n");
|
|
1073
|
+
} finally {
|
|
1074
|
+
rmSync(workspaceRoot, { recursive: true, force: true });
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
284
1077
|
});
|