nolo-cli 0.1.23 → 0.1.26

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.
Files changed (76) hide show
  1. package/README.md +25 -3
  2. package/agent-runtime/hybridRecordStore.ts +30 -9
  3. package/agent-runtime/localLoop.ts +12 -4
  4. package/agent-runtime/localToolPolicy.ts +22 -10
  5. package/agent-runtime/localWorkspaceTools.ts +140 -374
  6. package/agent-runtime/runtimeToolPolicy.ts +43 -2
  7. package/agent-runtime/types.ts +5 -0
  8. package/agentAliases.ts +1 -1
  9. package/agentCommandSupport.ts +77 -0
  10. package/agentInternalCommandEntries.ts +34 -0
  11. package/agentListCommands.ts +123 -0
  12. package/agentListHelpers.ts +187 -0
  13. package/agentMachineCommands.ts +402 -0
  14. package/agentRecordCommands.ts +141 -0
  15. package/agentRecordHelpers.ts +395 -0
  16. package/agentRunCommand.ts +1 -2
  17. package/agentRuntimeCommands.ts +7 -701
  18. package/ai/agent/_executeModel.ts +29 -3
  19. package/ai/agent/buildSystemPrompt.ts +17 -0
  20. package/ai/agent/cliChatClient.ts +38 -4
  21. package/ai/agent/cliExecutor.ts +304 -25
  22. package/ai/agent/createAgentSchema.ts +2 -2
  23. package/ai/agent/guidedCreation/assistant.ts +82 -0
  24. package/ai/agent/guidedCreation/capabilities.ts +84 -0
  25. package/ai/agent/guidedCreation/draft.ts +62 -0
  26. package/ai/agent/guidedCreation/types.ts +38 -0
  27. package/ai/agent/machineRunPermissions.ts +21 -3
  28. package/ai/agent/runtimeGuidance.ts +1 -1
  29. package/ai/agent/server/fetchPublicAgents.ts +6 -1
  30. package/ai/agent/streamAgentChatTurn.ts +492 -19
  31. package/ai/agent/web/useGuidedAgentCreation.ts +217 -0
  32. package/ai/ai.locale.ts +14 -2
  33. package/ai/chat/sendOpenAICompletionsRequest.ts +28 -3
  34. package/ai/chat/sendOpenAIResponseRequest.ts +3 -2
  35. package/ai/llm/getPricing.ts +14 -0
  36. package/ai/llm/kimi.ts +3 -0
  37. package/ai/llm/modelAvatar.ts +1 -0
  38. package/ai/llm/providers.ts +1 -1
  39. package/ai/taskRun/taskRunProtocol.ts +24 -10
  40. package/ai/token/modelUsageQuery.ts +17 -0
  41. package/ai/tools/agent/agentTools.ts +21 -0
  42. package/ai/tools/agent/prepareAgentDraftTool.ts +159 -0
  43. package/ai/tools/checkEnvTool.ts +52 -20
  44. package/ai/tools/convertMarxistsBookTool.ts +103 -0
  45. package/ai/tools/{execBashTool.ts → execShellTool.ts} +44 -161
  46. package/ai/tools/index.ts +24 -14
  47. package/ai/tools/marxistsOfflineBook.ts +253 -0
  48. package/ai/tools/modelUsageTools.ts +4 -2
  49. package/ai/tools/readTool.ts +31 -0
  50. package/ai/tools/toolApiClient.ts +35 -1
  51. package/ai/tools/toolNameAliases.ts +13 -0
  52. package/cliCommandAdapters.ts +28 -0
  53. package/cliCommandDispatch.ts +66 -0
  54. package/cliCommandFactories.ts +34 -0
  55. package/cliCommandTypes.ts +49 -0
  56. package/cliEnvHelpers.ts +44 -0
  57. package/client/agentRun.test.ts +123 -19
  58. package/client/agentRun.ts +40 -1
  59. package/client/hybridRecordStore.test.ts +42 -0
  60. package/client/hybridRecordStore.ts +11 -0
  61. package/client/localRuntimeAdapter.test.ts +189 -21
  62. package/client/localRuntimeAdapter.ts +84 -8
  63. package/client/localRuntimeDryRun.test.ts +5 -2
  64. package/client/taskRunPrompt.ts +1 -0
  65. package/commandRegistry.ts +11 -61
  66. package/connector-experimental/capabilities.ts +1 -0
  67. package/database/server/coreDataProxy.ts +43 -26
  68. package/index.ts +54 -191
  69. package/internalCommandEntries.ts +12 -0
  70. package/machineCommands.ts +301 -32
  71. package/offlineMarxistsAgentCommand.ts +314 -0
  72. package/package.json +20 -2
  73. package/runtimeDoctorCommands.ts +118 -0
  74. package/scriptCommandEntries.ts +71 -0
  75. package/systemInternalCommandEntries.ts +71 -0
  76. package/workflowInternalCommandEntries.ts +11 -0
@@ -0,0 +1,44 @@
1
+ import { DEFAULT_NOLO_SERVER_URL } from "./defaultServer";
2
+ import { NOLO_CLUSTER_SERVERS } from "./database/config";
3
+
4
+ export type EnvLike = Record<string, string | undefined>;
5
+
6
+ export function resolveServerUrl(env: EnvLike) {
7
+ return (env.NOLO_SERVER || env.BASE_URL || DEFAULT_NOLO_SERVER_URL).replace(/\/+$/, "");
8
+ }
9
+
10
+ export function resolveServerCandidates(env: EnvLike, preferred?: string | null) {
11
+ const values = [
12
+ preferred?.trim(),
13
+ env.NOLO_SERVER,
14
+ env.BASE_URL,
15
+ env.NOLO_SERVER_URL,
16
+ DEFAULT_NOLO_SERVER_URL,
17
+ ...NOLO_CLUSTER_SERVERS,
18
+ ];
19
+ return [...new Set(
20
+ values
21
+ .filter((value): value is string => typeof value === "string" && value.trim().length > 0)
22
+ .map((value) => value.replace(/\/+$/, ""))
23
+ )];
24
+ }
25
+
26
+ export function resolveAuthToken(env: EnvLike) {
27
+ return env.AUTH_TOKEN || env.AUTH || "";
28
+ }
29
+
30
+ export function parseUserIdFromAuthToken(token: string) {
31
+ const payload = token.trim().split(".")[0];
32
+ if (!payload) return "";
33
+ try {
34
+ const parsed = JSON.parse(Buffer.from(payload, "base64").toString("utf8"));
35
+ return typeof parsed?.userId === "string" ? parsed.userId : "";
36
+ } catch {
37
+ return "";
38
+ }
39
+ }
40
+
41
+ export function readOption(args: string[], flag: string) {
42
+ const index = args.indexOf(flag);
43
+ return index >= 0 ? args[index + 1] : undefined;
44
+ }
@@ -45,7 +45,7 @@ describe("cli agent run client", () => {
45
45
  "queryTableRows",
46
46
  "updateTableRow",
47
47
  ]);
48
- expect(findServerPlatformTools(["readWorkspaceFile", "gitStatus"])).toEqual([]);
48
+ expect(findServerPlatformTools(["readWorkspaceFile", "execShell"])).toEqual([]);
49
49
  });
50
50
 
51
51
  test("calls the Nolo HTTP API directly when AUTH_TOKEN is present", async () => {
@@ -84,15 +84,15 @@ describe("cli agent run client", () => {
84
84
  agentKey: "agent-pub-test",
85
85
  userInput: "hello",
86
86
  continueDialogId: "dialog-existing",
87
- runtimeContext: {
88
- surface: "cli",
89
- host: "terminal",
90
- runtime: "bun",
91
- entrypoint: "nolo-cli",
92
- capabilities: ["text-io", "streaming", "slash-commands"],
93
- },
94
- stream: true,
95
- },
87
+ runtimeContext: {
88
+ surface: "cli",
89
+ host: "terminal",
90
+ runtime: "bun",
91
+ entrypoint: "nolo-cli",
92
+ capabilities: ["text-io", "streaming", "slash-commands"],
93
+ },
94
+ stream: true,
95
+ },
96
96
  },
97
97
  ]);
98
98
  expect(result).toEqual({ exitCode: 0, dialogId: "dialog-1" });
@@ -463,7 +463,7 @@ describe("cli agent run client", () => {
463
463
  name: "Frontend",
464
464
  prompt: "Fix UI",
465
465
  model: "fake-local",
466
- toolNames: ["gitStatus"],
466
+ toolNames: ["readWorkspaceFile"],
467
467
  }),
468
468
  loadDialogHistory: async () => [],
469
469
  saveTurn: async () => ({ dialogId: "dialog-local" }),
@@ -476,9 +476,9 @@ describe("cli agent run client", () => {
476
476
  content: "",
477
477
  model: "fake-local",
478
478
  tool_calls: [{
479
- id: "call-status",
479
+ id: "call-read",
480
480
  type: "function",
481
- function: { name: "gitStatus", arguments: "{}" },
481
+ function: { name: "readWorkspaceFile", arguments: JSON.stringify({ path: "README.md" }) },
482
482
  }],
483
483
  };
484
484
  }
@@ -491,8 +491,8 @@ describe("cli agent run client", () => {
491
491
  });
492
492
 
493
493
  expect(result).toMatchObject({ exitCode: 0, dialogId: "dialog-local" });
494
- expect(output.text()).toContain("[nolo:tool] round 1 -> gitStatus (call-status)");
495
- expect(output.text()).toContain("[nolo:tool] round 1 <- gitStatus (call-status)");
494
+ expect(output.text()).toContain("[nolo:tool] round 1 -> readWorkspaceFile (call-read)");
495
+ expect(output.text()).toContain("[nolo:tool] round 1 <- readWorkspaceFile (call-read)");
496
496
  });
497
497
 
498
498
  test("passes local tool evidence to task-run writeback", async () => {
@@ -524,7 +524,7 @@ describe("cli agent run client", () => {
524
524
  name: "Frontend",
525
525
  prompt: "Fix UI",
526
526
  model: "fake-local",
527
- toolNames: ["gitStatus"],
527
+ toolNames: ["readWorkspaceFile"],
528
528
  }),
529
529
  loadDialogHistory: async () => [],
530
530
  saveTurn: async () => ({ dialogId: "dialog-local" }),
@@ -537,9 +537,9 @@ describe("cli agent run client", () => {
537
537
  content: "",
538
538
  model: "fake-local",
539
539
  tool_calls: [{
540
- id: "call-status",
540
+ id: "call-read",
541
541
  type: "function",
542
- function: { name: "gitStatus", arguments: "{}" },
542
+ function: { name: "readWorkspaceFile", arguments: JSON.stringify({ path: "README.md" }) },
543
543
  }],
544
544
  };
545
545
  }
@@ -554,7 +554,7 @@ describe("cli agent run client", () => {
554
554
  expect(result).toMatchObject({ exitCode: 0, dialogId: "dialog-local" });
555
555
  expect(writebacks).toHaveLength(1);
556
556
  expect(writebacks[0].toolEvidence).toEqual({
557
- toolsUsed: ["gitStatus"],
557
+ toolsUsed: ["readWorkspaceFile"],
558
558
  summary: {
559
559
  callCount: 1,
560
560
  resultCount: 1,
@@ -660,6 +660,110 @@ describe("cli agent run client", () => {
660
660
  expect(output.text()).toContain("frontend -> working locally");
661
661
  });
662
662
 
663
+ test("auto mode refreshes missing local agent config and retries local once before HTTP", async () => {
664
+ const output = new CaptureOutput();
665
+ const httpCalls: string[] = [];
666
+ let loadAgentConfigCalls = 0;
667
+ let localCompletions = 0;
668
+
669
+ const result = await runAgentTurn({
670
+ agentName: "frontend",
671
+ agentKey: "frontend-local",
672
+ serverUrl: "https://nolo.chat",
673
+ message: "polish notifications",
674
+ scriptDir: "C:/missing/scripts",
675
+ env: { AUTH_TOKEN: "token-123" },
676
+ output,
677
+ runtimeMode: "auto",
678
+ localRuntimeAdapter: {
679
+ host: "cli",
680
+ capabilities: ["leveldb-agent-config", "local-provider", "leveldb-persistence"],
681
+ loadAgentConfig: async (agentRef) => {
682
+ loadAgentConfigCalls += 1;
683
+ if (loadAgentConfigCalls < 3) return null;
684
+ return {
685
+ key: agentRef,
686
+ name: "Frontend",
687
+ prompt: "Fix UI",
688
+ model: "fake-local",
689
+ };
690
+ },
691
+ loadDialogHistory: async () => [],
692
+ saveTurn: async () => ({ dialogId: "dialog-auto-local-retried" }),
693
+ resolveProvider: async () => ({
694
+ model: "fake-local",
695
+ complete: async () => {
696
+ localCompletions += 1;
697
+ return { content: "auto local after refresh", model: "fake-local" };
698
+ },
699
+ }),
700
+ executeTool: async () => {
701
+ throw new Error("no tools expected");
702
+ },
703
+ },
704
+ scriptPathExists: () => false,
705
+ fetchImpl: async (url) => {
706
+ httpCalls.push(String(url));
707
+ return Response.json({ content: "server" });
708
+ },
709
+ });
710
+
711
+ expect(result).toEqual({ exitCode: 0, dialogId: "dialog-auto-local-retried" });
712
+ expect(loadAgentConfigCalls).toBe(4);
713
+ expect(localCompletions).toBe(1);
714
+ expect(httpCalls).toEqual([]);
715
+ expect(output.text()).toContain("refreshing from the configured server and retrying local once");
716
+ expect(output.text().match(/working locally/g)?.length).toBe(2);
717
+ });
718
+
719
+ test("auto mode falls back to HTTP when local agent config is still missing after one refresh retry", async () => {
720
+ const output = new CaptureOutput();
721
+ const httpCalls: Array<{ url: string; body: any }> = [];
722
+ let loadAgentConfigCalls = 0;
723
+
724
+ const result = await runAgentTurn({
725
+ agentName: "frontend",
726
+ agentKey: "frontend-local",
727
+ serverUrl: "https://nolo.chat",
728
+ message: "polish notifications",
729
+ scriptDir: "C:/missing/scripts",
730
+ env: { AUTH_TOKEN: "token-123" },
731
+ output,
732
+ runtimeMode: "auto",
733
+ localRuntimeAdapter: {
734
+ host: "cli",
735
+ capabilities: ["leveldb-agent-config", "local-provider", "leveldb-persistence"],
736
+ loadAgentConfig: async () => {
737
+ loadAgentConfigCalls += 1;
738
+ return null;
739
+ },
740
+ loadDialogHistory: async () => [],
741
+ saveTurn: async () => {
742
+ throw new Error("local runtime should not save when config is missing");
743
+ },
744
+ resolveProvider: async () => {
745
+ throw new Error("local provider should not run when config is missing");
746
+ },
747
+ executeTool: async () => {
748
+ throw new Error("no tools expected");
749
+ },
750
+ },
751
+ scriptPathExists: () => false,
752
+ fetchImpl: async (url, init) => {
753
+ httpCalls.push({ url: String(url), body: JSON.parse(String(init?.body)) });
754
+ return Response.json({ content: "server fallback", dialogId: "dialog-server-fallback" });
755
+ },
756
+ });
757
+
758
+ expect(result).toEqual({ exitCode: 0, dialogId: "dialog-server-fallback" });
759
+ expect(loadAgentConfigCalls).toBe(3);
760
+ expect(httpCalls).toHaveLength(1);
761
+ expect(httpCalls[0]?.body.agentKey).toBe("frontend-local");
762
+ expect(output.text()).toContain("refreshing from the configured server and retrying local once");
763
+ expect(output.text()).toContain("frontend -> working");
764
+ expect(output.text()).toContain("frontend > server fallback");
765
+ });
766
+
663
767
  test("auto mode skips local runtime for agents that declare server platform tools", async () => {
664
768
  const output = new CaptureOutput();
665
769
  const httpCalls: Array<{ url: string; body: any }> = [];
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { runLocalAgentTurn } from "../agentRuntimeLocal";
4
+ import { LOCAL_AGENT_CONFIG_MISSING_CODE } from "../agent-runtime/localLoop";
4
5
  import {
5
6
  FRONTEND_IMPLEMENTER_AGENT_KEY,
6
7
  NOLO_FULLSTACK_AGENT_KEY,
@@ -250,7 +251,7 @@ function summarizeLocalToolEvidence(events: LocalAgentToolEvent[]): TaskRunToolE
250
251
  };
251
252
  }
252
253
 
253
- const DEFAULT_LOCAL_AGENT_TOOL_ROUND_RETRY_LIMIT = 128;
254
+ const DEFAULT_LOCAL_AGENT_TOOL_ROUND_RETRY_LIMIT = 512;
254
255
 
255
256
  function parseLocalToolRoundLimitError(error: unknown) {
256
257
  const message = error instanceof Error ? error.message : String(error);
@@ -259,6 +260,16 @@ function parseLocalToolRoundLimitError(error: unknown) {
259
260
  return Number(match[1]);
260
261
  }
261
262
 
263
+ function isMissingLocalAgentConfigError(error: unknown, agentRef: string) {
264
+ return Boolean(
265
+ error &&
266
+ typeof error === "object" &&
267
+ "code" in error &&
268
+ (error as { code?: string; agentRef?: string }).code === LOCAL_AGENT_CONFIG_MISSING_CODE &&
269
+ (error as { code?: string; agentRef?: string }).agentRef === agentRef
270
+ );
271
+ }
272
+
262
273
  function parsePositiveInteger(value: string | undefined) {
263
274
  if (!value) return null;
264
275
  const parsed = Number(value);
@@ -683,6 +694,13 @@ async function runInjectedLocalAgentTurn(options: RunAgentTurnOptions) {
683
694
  return runLocalAgentTurnWithAutoRoundExpansion(options, { reportFailure: true });
684
695
  }
685
696
 
697
+ async function refreshMissingLocalAgentConfig(options: RunAgentTurnOptions) {
698
+ const adapter = resolveLocalRuntimeAdapter(options);
699
+ if (!adapter) return false;
700
+ const agentConfig = await adapter.loadAgentConfig(options.agentKey);
701
+ return Boolean(agentConfig);
702
+ }
703
+
686
704
  async function runLocalAgentTurnWithAutoRoundExpansion(
687
705
  options: RunAgentTurnOptions,
688
706
  settings: { reportFailure: boolean }
@@ -926,6 +944,27 @@ export async function runAgentTurn(options: RunAgentTurnOptions) {
926
944
  ...(localResult.dialogId ? { dialogId: localResult.dialogId } : {}),
927
945
  };
928
946
  }
947
+ if (isMissingLocalAgentConfigError(localResult.localError, options.agentKey)) {
948
+ options.output.write(
949
+ `[nolo] Local agent config was missing; refreshing from the configured server and retrying local once.\n`
950
+ );
951
+ try {
952
+ const refreshed = await refreshMissingLocalAgentConfig(options);
953
+ if (refreshed) {
954
+ const retriedLocalResult = await runLocalAgentTurnWithAutoRoundExpansion(options, {
955
+ reportFailure: false,
956
+ });
957
+ if (retriedLocalResult.exitCode === 0) {
958
+ return {
959
+ exitCode: retriedLocalResult.exitCode,
960
+ ...(retriedLocalResult.dialogId ? { dialogId: retriedLocalResult.dialogId } : {}),
961
+ };
962
+ }
963
+ }
964
+ } catch {
965
+ // Fall through to the existing server runtime fallback below.
966
+ }
967
+ }
929
968
  }
930
969
  }
931
970
 
@@ -87,6 +87,48 @@ describe("CLI hybrid record store", () => {
87
87
  });
88
88
  });
89
89
 
90
+ test("falls back across known cluster servers on local miss and caches the first remote hit", async () => {
91
+ const { db, store: memory } = createMemoryDb();
92
+ const requests: string[] = [];
93
+ const hybrid = createCliHybridRecordStore({
94
+ db,
95
+ env: {
96
+ NOLO_SERVER: "http://127.0.0.1:38123",
97
+ AUTH_TOKEN: "token-1",
98
+ },
99
+ fetchImpl: async (url) => {
100
+ const target = String(url);
101
+ requests.push(target);
102
+ if (target.startsWith("http://127.0.0.1:38123/")) {
103
+ throw new Error("ConnectionRefused");
104
+ }
105
+ if (target.startsWith("https://nolo.chat/")) {
106
+ return Response.json({
107
+ data: {
108
+ dbKey: "agent-user-1-cluster",
109
+ name: "cluster remote",
110
+ updatedAt: "2026-05-24T00:00:00.000Z",
111
+ },
112
+ });
113
+ }
114
+ return new Response(null, { status: 404 });
115
+ },
116
+ });
117
+
118
+ await expect(hybrid.read("agent-user-1-cluster")).resolves.toMatchObject({
119
+ name: "cluster remote",
120
+ serverOrigin: "https://nolo.chat",
121
+ });
122
+ expect(requests).toEqual([
123
+ "http://127.0.0.1:38123/api/v1/db/read/agent-user-1-cluster",
124
+ "https://nolo.chat/api/v1/db/read/agent-user-1-cluster",
125
+ ]);
126
+ expect(memory.get("agent-user-1-cluster")).toMatchObject({
127
+ name: "cluster remote",
128
+ serverOrigin: "https://nolo.chat",
129
+ });
130
+ });
131
+
90
132
  test("keeps newer local records over stale remote records", () => {
91
133
  expect(shouldCacheRemoteRecord(
92
134
  { updatedAt: "2026-05-12T00:00:00.000Z" },
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_NOLO_SERVER_URL } from "../defaultServer";
2
+ import { NOLO_CLUSTER_SERVERS } from "../database/config";
2
3
  import {
3
4
  createHybridRecordStore,
4
5
  shouldCacheHybridRemoteRecord,
@@ -21,6 +22,15 @@ function normalizeServer(value: string) {
21
22
  return value.trim().replace(/\/+$/, "");
22
23
  }
23
24
 
25
+ function resolveFallbackServers(env: EnvLike) {
26
+ const values = [
27
+ env.NOLO_SERVER_URL,
28
+ env.BASE_URL,
29
+ ...NOLO_CLUSTER_SERVERS,
30
+ ].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
31
+ return [...new Set(values.map(normalizeServer))];
32
+ }
33
+
24
34
  function resolveAuthToken(env: EnvLike) {
25
35
  return env.AUTH_TOKEN || env.AUTH || env.BENCHMARK_AUTH_TOKEN || "";
26
36
  }
@@ -35,6 +45,7 @@ export function createCliHybridRecordStore(
35
45
  return createHybridRecordStore({
36
46
  db: deps.db,
37
47
  defaultServer: normalizeServer(deps.env.NOLO_SERVER || deps.env.BASE_URL || DEFAULT_NOLO_SERVER_URL),
48
+ fallbackServers: resolveFallbackServers(deps.env),
38
49
  authToken: resolveAuthToken(deps.env),
39
50
  fetchImpl: deps.fetchImpl,
40
51
  });
@@ -8,26 +8,22 @@ import { createCliLocalRuntimeAdapter } from "./localRuntimeAdapter";
8
8
 
9
9
  describe("CLI local runtime adapter", () => {
10
10
  const DEFAULT_LOCAL_CODING_TOOL_NAMES = [
11
- "readWorkspaceFile",
11
+ "listFiles",
12
+ "readFile",
13
+ "writeFile",
14
+ "editFile",
15
+ "searchFiles",
16
+ ];
17
+ const LEGACY_WRITE_LOCAL_CODING_TOOL_NAMES = [
12
18
  "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",
19
+ "listFiles",
20
+ "readFile",
21
+ "editFile",
22
+ "searchFiles",
26
23
  ];
27
24
  const SHELL_LOCAL_CODING_TOOL_NAMES = [
28
25
  ...DEFAULT_LOCAL_CODING_TOOL_NAMES,
29
26
  "execShell",
30
- "execBash",
31
27
  ];
32
28
 
33
29
  function toolNamesFromRequest(request: any) {
@@ -211,6 +207,77 @@ describe("CLI local runtime adapter", () => {
211
207
  });
212
208
  });
213
209
 
210
+ test("keeps local runtime runnable when the configured local server is down but a cluster server can provide agent config", async () => {
211
+ const memory = new Map<string, any>();
212
+ const requests: string[] = [];
213
+ const adapter = createCliLocalRuntimeAdapter({
214
+ env: {
215
+ NOLO_LOCAL_USER_ID: "user-1",
216
+ NOLO_SERVER: "http://127.0.0.1:38123",
217
+ AUTH_TOKEN: "token-1",
218
+ OPENAI_API_KEY: "sk-local",
219
+ NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
220
+ },
221
+ db: {
222
+ get: async (key) => {
223
+ if (!memory.has(key)) throw new Error(`not found: ${key}`);
224
+ return memory.get(key);
225
+ },
226
+ put: async (key, value) => {
227
+ memory.set(key, value);
228
+ },
229
+ batch: async (ops) => {
230
+ for (const op of ops) {
231
+ if (op.type === "put") memory.set(op.key, op.value);
232
+ }
233
+ },
234
+ iterator: () => (async function* () {})(),
235
+ },
236
+ createId: () => "01CLUSTER",
237
+ fetchImpl: async (url, init) => {
238
+ const target = String(url);
239
+ requests.push(target);
240
+ if (target === "http://127.0.0.1:38123/api/v1/db/read/agent-user-1-cluster") {
241
+ throw new Error("ConnectionRefused");
242
+ }
243
+ if (target === "https://nolo.chat/api/v1/db/read/agent-user-1-cluster") {
244
+ expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer token-1");
245
+ return Response.json({
246
+ data: {
247
+ dbKey: "agent-user-1-cluster",
248
+ name: "Cluster cached",
249
+ prompt: "Use cached config",
250
+ model: "gpt-4.1-mini",
251
+ },
252
+ });
253
+ }
254
+ if (target === "http://127.0.0.1:11434/v1/chat/completions") {
255
+ return Response.json({
256
+ choices: [{ message: { content: "cluster cache ok" } }],
257
+ });
258
+ }
259
+ return new Response(null, { status: 404 });
260
+ },
261
+ });
262
+
263
+ const result = await runLocalAgentTurn({
264
+ adapter,
265
+ agentRef: "agent-user-1-cluster",
266
+ input: "hello",
267
+ });
268
+
269
+ expect(result.content).toBe("cluster cache ok");
270
+ expect(requests).toEqual([
271
+ "http://127.0.0.1:38123/api/v1/db/read/agent-user-1-cluster",
272
+ "https://nolo.chat/api/v1/db/read/agent-user-1-cluster",
273
+ "http://127.0.0.1:11434/v1/chat/completions",
274
+ ]);
275
+ expect(memory.get("agent-user-1-cluster")).toMatchObject({
276
+ name: "Cluster cached",
277
+ serverOrigin: "https://nolo.chat",
278
+ });
279
+ });
280
+
214
281
  test("uses agent customProviderUrl for local OpenAI-compatible requests", async () => {
215
282
  const requests: Array<{ url: string; auth: string | null; body: any }> = [];
216
283
  const store = new Map<string, any>([
@@ -356,7 +423,7 @@ describe("CLI local runtime adapter", () => {
356
423
  agentKey: "agent-user-1-frontend",
357
424
  },
358
425
  });
359
- expect(toolNamesFromRequest(requests[0])).toEqual(DEFAULT_LOCAL_CODING_TOOL_NAMES);
426
+ expect(toolNamesFromRequest(requests[0])).toEqual(LEGACY_WRITE_LOCAL_CODING_TOOL_NAMES);
360
427
  });
361
428
 
362
429
  test("uses the Nolo chat proxy for platform agents even when direct provider env exists", async () => {
@@ -370,7 +437,7 @@ describe("CLI local runtime adapter", () => {
370
437
  provider: "fireworks",
371
438
  apiSource: "platform",
372
439
  useServerProxy: true,
373
- tools: ["gitStatus"],
440
+ tools: ["readWorkspaceFile"],
374
441
  }],
375
442
  ]);
376
443
  const adapter = createCliLocalRuntimeAdapter({
@@ -575,6 +642,12 @@ describe("CLI local runtime adapter", () => {
575
642
  cwd: import.meta.dir,
576
643
  now: () => 1710000000000,
577
644
  createId: () => "01TRACE",
645
+ localToolExecutors: {
646
+ execShell: async () => ({
647
+ content: "stdout:\ntrace-ok\n\nexitCode: 0",
648
+ metadata: { exitCode: 0, timedOut: false },
649
+ }),
650
+ },
578
651
  fetchImpl: async (_url, init) => {
579
652
  const body = JSON.parse(String(init?.body));
580
653
  const hasToolResult = body.messages.some((message: any) => message.role === "tool");
@@ -588,7 +661,11 @@ describe("CLI local runtime adapter", () => {
588
661
  type: "function",
589
662
  function: {
590
663
  name: "execShell",
591
- arguments: JSON.stringify({ cmd: "printf trace-ok" }),
664
+ arguments: JSON.stringify({
665
+ cmd: process.platform === "win32"
666
+ ? "Write-Output trace-ok"
667
+ : "printf trace-ok",
668
+ }),
592
669
  },
593
670
  }],
594
671
  },
@@ -953,7 +1030,7 @@ describe("CLI local runtime adapter", () => {
953
1030
  await adapter.loadAgentConfig("default-tools");
954
1031
  await expect(adapter.executeTool({
955
1032
  id: "call-read",
956
- name: "readWorkspaceFile",
1033
+ name: "readFile",
957
1034
  arguments: JSON.stringify({ path: "README.md" }),
958
1035
  })).resolves.toMatchObject({
959
1036
  content: "local ok\n",
@@ -963,6 +1040,91 @@ describe("CLI local runtime adapter", () => {
963
1040
  }
964
1041
  });
965
1042
 
1043
+ test("exposes declared taskRun as a platform tool in local runtime requests", async () => {
1044
+ const requests: Array<{ body: any }> = [];
1045
+ const adapter = createCliLocalRuntimeAdapter({
1046
+ env: {
1047
+ OPENAI_API_KEY: "sk-local",
1048
+ NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
1049
+ },
1050
+ db: {
1051
+ get: async () => ({
1052
+ dbKey: "agent-local-taskrun",
1053
+ prompt: "Use taskRun.",
1054
+ model: "gpt-4.1-mini",
1055
+ toolNames: ["taskRun"],
1056
+ }),
1057
+ put: async () => {},
1058
+ batch: async () => {},
1059
+ iterator: () => (async function* () {})(),
1060
+ },
1061
+ fetchImpl: async (_url, init) => {
1062
+ requests.push({ body: JSON.parse(String(init?.body)) });
1063
+ return Response.json({
1064
+ choices: [{ message: { content: "done" } }],
1065
+ });
1066
+ },
1067
+ });
1068
+
1069
+ await runLocalAgentTurn({
1070
+ adapter,
1071
+ agentRef: "taskrun",
1072
+ input: "read task context",
1073
+ });
1074
+
1075
+ expect(toolNamesFromRequest(requests[0])).toContain("taskRun");
1076
+ });
1077
+
1078
+ test("executes declared taskRun through the server task-run bridge", async () => {
1079
+ const requests: Array<{ url: string; auth: string | null; body: any }> = [];
1080
+ const adapter = createCliLocalRuntimeAdapter({
1081
+ env: {
1082
+ BASE_URL: "https://us.nolo.chat",
1083
+ AUTH_TOKEN: "machine-token",
1084
+ },
1085
+ db: {
1086
+ get: async () => ({
1087
+ dbKey: "agent-local-taskrun",
1088
+ toolNames: ["taskRun"],
1089
+ }),
1090
+ put: async () => {},
1091
+ batch: async () => {},
1092
+ iterator: () => (async function* () {})(),
1093
+ },
1094
+ fetchImpl: async (url, init) => {
1095
+ requests.push({
1096
+ url: String(url),
1097
+ auth: new Headers(init?.headers).get("authorization"),
1098
+ body: JSON.parse(String(init?.body)),
1099
+ });
1100
+ return Response.json({ ok: true, row: { rowId: "task-1" } });
1101
+ },
1102
+ });
1103
+
1104
+ await adapter.loadAgentConfig("taskrun");
1105
+ const result = await adapter.executeTool({
1106
+ id: "call-taskrun",
1107
+ name: "taskRun",
1108
+ arguments: JSON.stringify({
1109
+ action: "readTaskContext",
1110
+ dbKey: "row-user-NOLOTASKBOARD-task-1",
1111
+ }),
1112
+ });
1113
+
1114
+ expect(requests).toEqual([
1115
+ {
1116
+ url: "https://us.nolo.chat/api/task-run",
1117
+ auth: "Bearer machine-token",
1118
+ body: {
1119
+ action: "readTaskContext",
1120
+ dbKey: "row-user-NOLOTASKBOARD-task-1",
1121
+ },
1122
+ },
1123
+ ]);
1124
+ expect(result.content).toContain("\"ok\":true");
1125
+ expect(result.metadata).toMatchObject({ serverPlatformTool: true });
1126
+ });
1127
+
966
1128
  test("activates an agent-declared task worktree before running workspace tools", async () => {
967
1129
  const taskWorktreeRoot = mkdtempSync(join(tmpdir(), "nolo-cli-runtime-worktree-"));
968
1130
  const chunks: string[] = [];
@@ -994,7 +1156,7 @@ describe("CLI local runtime adapter", () => {
994
1156
  await adapter.loadAgentConfig("frontend");
995
1157
  const result = await adapter.executeTool({
996
1158
  id: "call-read",
997
- name: "readWorkspaceFile",
1159
+ name: "readFile",
998
1160
  arguments: JSON.stringify({ path: "README.md" }),
999
1161
  });
1000
1162
 
@@ -1019,6 +1181,12 @@ describe("CLI local runtime adapter", () => {
1019
1181
  iterator: () => (async function* () {})(),
1020
1182
  },
1021
1183
  cwd: import.meta.dir,
1184
+ localToolExecutors: {
1185
+ execShell: async () => ({
1186
+ content: import.meta.dir,
1187
+ metadata: { exitCode: 0, timedOut: false },
1188
+ }),
1189
+ },
1022
1190
  fetchImpl: async () => Response.json({}),
1023
1191
  });
1024
1192
 
@@ -1070,7 +1238,7 @@ describe("CLI local runtime adapter", () => {
1070
1238
  }),
1071
1239
  });
1072
1240
 
1073
- expect(result.content).toBe("wrote src/app.ts");
1241
+ expect(result.content).toBe(`wrote ${join("src", "app.ts")}`);
1074
1242
  expect(readFileSync(join(workspaceRoot, "src/app.ts"), "utf8")).toBe("export const cliValue = 1;\n");
1075
1243
  } finally {
1076
1244
  rmSync(workspaceRoot, { recursive: true, force: true });