nolo-cli 0.1.22 → 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 (128) hide show
  1. package/README.md +25 -3
  2. package/agent-runtime/agentRecordConfig.ts +4 -0
  3. package/agent-runtime/hostAdapter.ts +2 -0
  4. package/agent-runtime/hybridRecordStore.ts +30 -9
  5. package/agent-runtime/index.ts +7 -0
  6. package/agent-runtime/localLoop.ts +14 -4
  7. package/agent-runtime/localToolPolicy.ts +22 -10
  8. package/agent-runtime/localWorkspaceTools.ts +140 -374
  9. package/agent-runtime/platformChatProvider.ts +3 -0
  10. package/agent-runtime/runtimeToolPolicy.ts +133 -0
  11. package/agent-runtime/types.ts +47 -0
  12. package/agentAliases.ts +1 -1
  13. package/agentCommandSupport.ts +77 -0
  14. package/agentInternalCommandEntries.ts +34 -0
  15. package/agentListCommands.ts +123 -0
  16. package/agentListHelpers.ts +187 -0
  17. package/agentMachineCommands.ts +402 -0
  18. package/agentRecordCommands.ts +141 -0
  19. package/agentRecordHelpers.ts +395 -0
  20. package/agentRunCommand.ts +75 -3
  21. package/agentRuntimeCommands.ts +7 -773
  22. package/ai/agent/_executeModel.ts +29 -3
  23. package/ai/agent/buildSystemPrompt.ts +17 -0
  24. package/ai/agent/cliChatClient.ts +38 -4
  25. package/ai/agent/cliExecutor.ts +304 -25
  26. package/ai/agent/createAgentSchema.ts +2 -2
  27. package/ai/agent/guidedCreation/assistant.ts +82 -0
  28. package/ai/agent/guidedCreation/capabilities.ts +84 -0
  29. package/ai/agent/guidedCreation/draft.ts +62 -0
  30. package/ai/agent/guidedCreation/types.ts +38 -0
  31. package/ai/agent/machineRunPermissions.ts +21 -3
  32. package/ai/agent/runtimeGuidance.ts +1 -1
  33. package/ai/agent/server/fetchPublicAgents.ts +6 -1
  34. package/ai/agent/streamAgentChatTurn.ts +593 -36
  35. package/ai/agent/web/useGuidedAgentCreation.ts +217 -0
  36. package/ai/ai.locale.ts +14 -2
  37. package/ai/chat/fetchUtils.native.ts +2 -0
  38. package/ai/chat/fetchUtils.ts +2 -0
  39. package/ai/chat/sendOpenAICompletionsRequest.ts +82 -1
  40. package/ai/chat/sendOpenAIResponseRequest.ts +65 -0
  41. package/ai/llm/getPricing.ts +14 -0
  42. package/ai/llm/kimi.ts +4 -1
  43. package/ai/llm/modelAvatar.ts +1 -0
  44. package/ai/llm/providers.ts +4 -1
  45. package/ai/llm/reasoningModels.ts +1 -0
  46. package/ai/taskRun/taskRunProtocol.ts +25 -10
  47. package/ai/token/modelUsageQuery.ts +17 -0
  48. package/ai/tools/agent/agentTools.ts +38 -0
  49. package/ai/tools/agent/prepareAgentDraftTool.ts +159 -0
  50. package/ai/tools/agent/startAgentDialogTool.ts +53 -0
  51. package/ai/tools/checkEnvTool.ts +52 -20
  52. package/ai/tools/convertMarxistsBookTool.ts +103 -0
  53. package/ai/tools/{execBashTool.ts → execShellTool.ts} +44 -161
  54. package/ai/tools/index.ts +24 -14
  55. package/ai/tools/marxistsOfflineBook.ts +253 -0
  56. package/ai/tools/modelUsageTools.ts +9 -2
  57. package/ai/tools/readTool.ts +31 -0
  58. package/ai/tools/toolApiClient.ts +35 -1
  59. package/ai/tools/toolNameAliases.ts +13 -0
  60. package/cliCommandAdapters.ts +28 -0
  61. package/cliCommandDispatch.ts +66 -0
  62. package/cliCommandFactories.ts +34 -0
  63. package/cliCommandTypes.ts +49 -0
  64. package/cliEnvHelpers.ts +44 -0
  65. package/client/agentRun.test.ts +330 -24
  66. package/client/agentRun.ts +117 -35
  67. package/client/hybridRecordStore.test.ts +42 -0
  68. package/client/hybridRecordStore.ts +11 -0
  69. package/client/localRuntimeAdapter.test.ts +191 -21
  70. package/client/localRuntimeAdapter.ts +99 -10
  71. package/client/localRuntimeDryRun.test.ts +5 -2
  72. package/client/taskRunPrompt.ts +1 -0
  73. package/commandRegistry.ts +11 -61
  74. package/connector-experimental/capabilities.ts +1 -0
  75. package/database/actions/common.ts +4 -3
  76. package/database/config.ts +19 -0
  77. package/database/server/coreDataProxy.ts +43 -26
  78. package/index.ts +54 -191
  79. package/internalCommandEntries.ts +12 -0
  80. package/machineCommands.ts +688 -64
  81. package/offlineMarxistsAgentCommand.ts +314 -0
  82. package/package.json +22 -2
  83. package/render/canvas/canvasEditContext.ts +127 -0
  84. package/render/canvas/canvasRuntime.ts +57 -0
  85. package/render/canvas/canvasSnapshotParser.ts +76 -0
  86. package/render/canvas/canvasTree.ts +308 -0
  87. package/render/canvas/types.ts +46 -0
  88. package/render/layout/deleteBehavior.ts +52 -0
  89. package/render/layout/mainLayoutSidebar.ts +17 -0
  90. package/render/layout/mainLayoutViewMode.ts +56 -0
  91. package/render/layout/topbarUtils.ts +87 -0
  92. package/render/layout/useDevReloadPending.ts +30 -0
  93. package/render/page/createPageAction.ts +183 -0
  94. package/render/page/docSlice.ts +468 -0
  95. package/render/page/server/createPage.ts +174 -0
  96. package/render/page/server/handleCreatePage.ts +91 -0
  97. package/render/page/server/index.ts +4 -0
  98. package/render/page/types.ts +17 -0
  99. package/render/page/useKeyboardSave.ts +48 -0
  100. package/render/styles/zIndex.ts +12 -0
  101. package/render/surf/WeatherIconStyles.ts +17 -0
  102. package/render/surf/color.ts +9 -0
  103. package/render/surf/config.ts +46 -0
  104. package/render/surf/screens/style.ts +1 -0
  105. package/render/surf/styles/ToggleButtonStyles.ts +8 -0
  106. package/render/surf/utils/groupedWeatherData.ts +32 -0
  107. package/render/surf/weatherUtils.ts +50 -0
  108. package/render/table/activityColumns.ts +6 -0
  109. package/render/table/createTableAction.ts +270 -0
  110. package/render/table/deleteTableAction.ts +129 -0
  111. package/render/table/fetchAndCacheTableRows.ts +174 -0
  112. package/render/table/tableSlice.ts +1106 -0
  113. package/render/table/tableView.ts +289 -0
  114. package/render/table/toolValueUtils.ts +363 -0
  115. package/render/table/types.ts +252 -0
  116. package/render/table/useCreateTable.ts +72 -0
  117. package/render/table/useTable.ts +61 -0
  118. package/render/table/utils/tableSerialization.ts +50 -0
  119. package/render/web/elements/artifactPreviewCode.ts +43 -0
  120. package/render/web/elements/artifactRuntimePreload.ts +52 -0
  121. package/render/web/elements/codeBlockAutoPreview.ts +10 -0
  122. package/render/web/elements/mermaidPreview.ts +21 -0
  123. package/render/web/ui/useInlineEdit.ts +135 -0
  124. package/runtimeDoctorCommands.ts +118 -0
  125. package/scriptCommandEntries.ts +71 -0
  126. package/systemInternalCommandEntries.ts +71 -0
  127. package/tableCommands.ts +42 -5
  128. package/workflowInternalCommandEntries.ts +11 -0
@@ -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,
@@ -59,7 +60,7 @@ type RunAgentTurnOptions = {
59
60
  taskRunContext?: TaskRunPromptContext;
60
61
  taskRunRecorder?: (args: {
61
62
  options: RunAgentTurnOptions;
62
- status: "completed" | "failed";
63
+ status: "running" | "completed" | "failed";
63
64
  dialogId?: string;
64
65
  resultSummary?: string;
65
66
  errorCode?: string;
@@ -69,10 +70,11 @@ type RunAgentTurnOptions = {
69
70
  fetchImpl?: typeof fetch;
70
71
  };
71
72
 
72
- export type RunAgentTurnResult = {
73
- exitCode: number;
74
- dialogId?: string;
75
- };
73
+ export type RunAgentTurnResult = {
74
+ exitCode: number;
75
+ dialogId?: string;
76
+ streamInterrupted?: boolean;
77
+ };
76
78
 
77
79
  type ScriptBridgeDecision = {
78
80
  hasAuthToken: boolean;
@@ -132,6 +134,15 @@ export function findServerPlatformTools(toolNames?: string[]) {
132
134
  return toolNames.filter((toolName) => SERVER_PLATFORM_TOOL_NAMES.has(toolName));
133
135
  }
134
136
 
137
+ function resolveServerPlatformToolNames(agentConfig: any) {
138
+ return findServerPlatformTools([
139
+ ...(Array.isArray(agentConfig?.toolNames) ? agentConfig.toolNames : []),
140
+ ...(Array.isArray(agentConfig?.runtimeToolPolicy?.agentTools)
141
+ ? agentConfig.runtimeToolPolicy.agentTools
142
+ : []),
143
+ ]);
144
+ }
145
+
135
146
  function normalizeAgentRef(ref?: string) {
136
147
  return ref?.trim().toLowerCase().replace(/\s+/g, " ");
137
148
  }
@@ -190,7 +201,7 @@ async function shouldSkipAutoLocalForServerPlatformTools(options: RunAgentTurnOp
190
201
  } catch {
191
202
  return false;
192
203
  }
193
- const serverTools = findServerPlatformTools(agentConfig?.toolNames);
204
+ const serverTools = resolveServerPlatformToolNames(agentConfig);
194
205
  if (serverTools.length === 0) return false;
195
206
  options.output.write(
196
207
  `[nolo] auto runtime: skipping local runtime because ${options.agentKey} declares server platform tools ` +
@@ -240,7 +251,7 @@ function summarizeLocalToolEvidence(events: LocalAgentToolEvent[]): TaskRunToolE
240
251
  };
241
252
  }
242
253
 
243
- const DEFAULT_LOCAL_AGENT_TOOL_ROUND_RETRY_LIMIT = 128;
254
+ const DEFAULT_LOCAL_AGENT_TOOL_ROUND_RETRY_LIMIT = 512;
244
255
 
245
256
  function parseLocalToolRoundLimitError(error: unknown) {
246
257
  const message = error instanceof Error ? error.message : String(error);
@@ -249,6 +260,16 @@ function parseLocalToolRoundLimitError(error: unknown) {
249
260
  return Number(match[1]);
250
261
  }
251
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
+
252
273
  function parsePositiveInteger(value: string | undefined) {
253
274
  if (!value) return null;
254
275
  const parsed = Number(value);
@@ -348,7 +369,7 @@ async function runTaskRunCli(args: {
348
369
 
349
370
  async function defaultTaskRunRecorder(args: {
350
371
  options: RunAgentTurnOptions;
351
- status: "completed" | "failed";
372
+ status: "running" | "completed" | "failed";
352
373
  dialogId?: string;
353
374
  resultSummary?: string;
354
375
  errorCode?: string;
@@ -357,7 +378,7 @@ async function defaultTaskRunRecorder(args: {
357
378
  const context = args.options.taskRunContext;
358
379
  if (!context?.rowDbKey) return;
359
380
  const role = inferTaskRunAgentRole(args.options.agentName || args.options.agentKey);
360
- if (role === "reviewer") {
381
+ if (role === "reviewer" && args.status !== "running") {
361
382
  await runTaskRunCli({
362
383
  options: args.options,
363
384
  cliArgs: [
@@ -415,7 +436,7 @@ async function defaultTaskRunRecorder(args: {
415
436
  ],
416
437
  });
417
438
  }
418
- if (role === "reviewer") {
439
+ if (role === "reviewer" && args.status !== "running") {
419
440
  const reviewStatus =
420
441
  args.status === "failed" ? "blocked" : classifyTaskRunReviewStatus(args.resultSummary);
421
442
  if (reviewStatus) {
@@ -441,7 +462,7 @@ async function defaultTaskRunRecorder(args: {
441
462
 
442
463
  async function recordTaskRunOutcome(args: {
443
464
  options: RunAgentTurnOptions;
444
- status: "completed" | "failed";
465
+ status: "running" | "completed" | "failed";
445
466
  dialogId?: string;
446
467
  resultSummary?: string;
447
468
  errorCode?: string;
@@ -566,12 +587,28 @@ async function runHttpAgentTurn(options: RunAgentTurnOptions, authToken: string)
566
587
  options.imageUrls
567
588
  ),
568
589
  runtimeContext: {
569
- surface: "cli",
570
- host: "terminal",
571
- runtime: "bun",
572
- entrypoint: "nolo-cli",
573
- capabilities: ["text-io", "streaming", "slash-commands"],
574
- },
590
+ surface: "cli",
591
+ host: "terminal",
592
+ runtime: "bun",
593
+ entrypoint: "nolo-cli",
594
+ capabilities: ["text-io", "streaming", "slash-commands"],
595
+ ...(options.taskRunContext
596
+ ? {
597
+ taskRun: {
598
+ rowDbKey: options.taskRunContext.rowDbKey,
599
+ ...(options.taskRunContext.taskRunId
600
+ ? { taskRunId: options.taskRunContext.taskRunId }
601
+ : {}),
602
+ ...(options.taskRunContext.workItemId
603
+ ? { workItemId: options.taskRunContext.workItemId }
604
+ : {}),
605
+ ...(options.taskRunContext.artifactIds?.length
606
+ ? { artifactIds: options.taskRunContext.artifactIds }
607
+ : {}),
608
+ },
609
+ }
610
+ : {}),
611
+ },
575
612
  ...(options.continueDialogId
576
613
  ? { continueDialogId: options.continueDialogId }
577
614
  : {}),
@@ -597,7 +634,11 @@ async function runHttpAgentTurn(options: RunAgentTurnOptions, authToken: string)
597
634
  const result = await readStreamingAgentRun(options, res);
598
635
  await recordTaskRunOutcome({
599
636
  options,
600
- status: result.exitCode === 0 ? "completed" : "failed",
637
+ status: result.streamInterrupted
638
+ ? "running"
639
+ : result.exitCode === 0
640
+ ? "completed"
641
+ : "failed",
601
642
  dialogId: result.dialogId,
602
643
  ...(result.exitCode !== 0 ? { errorCode: "STREAM_FAILED" } : {}),
603
644
  });
@@ -642,9 +683,9 @@ async function runHttpAgentTurn(options: RunAgentTurnOptions, authToken: string)
642
683
  };
643
684
  await recordTaskRunOutcome({
644
685
  options,
645
- status: "completed",
686
+ status: options.background ? "running" : "completed",
646
687
  dialogId: result.dialogId,
647
- resultSummary: content || undefined,
688
+ ...(!options.background && content ? { resultSummary: content } : {}),
648
689
  });
649
690
  return result;
650
691
  }
@@ -653,6 +694,13 @@ async function runInjectedLocalAgentTurn(options: RunAgentTurnOptions) {
653
694
  return runLocalAgentTurnWithAutoRoundExpansion(options, { reportFailure: true });
654
695
  }
655
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
+
656
704
  async function runLocalAgentTurnWithAutoRoundExpansion(
657
705
  options: RunAgentTurnOptions,
658
706
  settings: { reportFailure: boolean }
@@ -747,9 +795,9 @@ async function runLocalAgentTurnForCli(
747
795
  }
748
796
  await recordTaskRunOutcome({
749
797
  options,
750
- status: "completed",
798
+ status: options.background ? "running" : "completed",
751
799
  dialogId: result.dialogId,
752
- resultSummary: content || undefined,
800
+ ...(!options.background && content ? { resultSummary: content } : {}),
753
801
  toolEvidence: summarizeLocalToolEvidence(toolEvents),
754
802
  });
755
803
  return { exitCode: 0, dialogId: result.dialogId };
@@ -791,15 +839,20 @@ async function readStreamingAgentRun(
791
839
  hasPrintedLabel = true;
792
840
  };
793
841
 
794
- const handlePayload = (payload: any) => {
795
- if (payload?.error || payload?.type === "error") {
796
- throw new Error(String(payload.error || payload.message || "Agent stream failed"));
797
- }
798
- if (payload?.type === "done") {
799
- if (typeof payload.dialogId === "string") dialogId = payload.dialogId;
800
- usage = payload.usage;
801
- return;
802
- }
842
+ const handlePayload = (payload: any) => {
843
+ if (typeof payload?.dialogId === "string" && payload.dialogId.trim()) {
844
+ dialogId = payload.dialogId;
845
+ }
846
+ if (payload?.error || payload?.type === "error") {
847
+ throw new Error(String(payload.error || payload.message || "Agent stream failed"));
848
+ }
849
+ if (payload?.type === "done") {
850
+ usage = payload.usage;
851
+ return;
852
+ }
853
+ if (payload?.type === "dialog" || payload?.type === "status") {
854
+ return;
855
+ }
803
856
 
804
857
  const chunk =
805
858
  payload?.type === "text"
@@ -842,10 +895,18 @@ async function readStreamingAgentRun(
842
895
  .trim();
843
896
  if (raw) handlePayload(JSON.parse(raw));
844
897
  }
845
- } catch (error) {
846
- options.output.write(`\n[nolo] Agent stream failed: ${error instanceof Error ? error.message : String(error)}\n`);
847
- return { exitCode: 1 };
848
- } finally {
898
+ } catch (error) {
899
+ const message = error instanceof Error ? error.message : String(error);
900
+ if (dialogId) {
901
+ options.output.write(
902
+ `\n[nolo] Agent stream transport interrupted after dialog ${dialogId} was created: ${message}\n`
903
+ );
904
+ options.output.write("[nolo] The agent run may still finish on the server; read the dialog before retrying.\n");
905
+ return { exitCode: 0, dialogId, streamInterrupted: true };
906
+ }
907
+ options.output.write(`\n[nolo] Agent stream failed: ${message}\n`);
908
+ return { exitCode: 1 };
909
+ } finally {
849
910
  writer.flushAll();
850
911
  }
851
912
 
@@ -883,6 +944,27 @@ export async function runAgentTurn(options: RunAgentTurnOptions) {
883
944
  ...(localResult.dialogId ? { dialogId: localResult.dialogId } : {}),
884
945
  };
885
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
+ }
886
968
  }
887
969
  }
888
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>([
@@ -353,9 +420,10 @@ describe("CLI local runtime adapter", () => {
353
420
  tool_choice: "auto",
354
421
  url: "https://api.fireworks.ai/inference/v1/chat/completions",
355
422
  provider: "fireworks",
423
+ agentKey: "agent-user-1-frontend",
356
424
  },
357
425
  });
358
- expect(toolNamesFromRequest(requests[0])).toEqual(DEFAULT_LOCAL_CODING_TOOL_NAMES);
426
+ expect(toolNamesFromRequest(requests[0])).toEqual(LEGACY_WRITE_LOCAL_CODING_TOOL_NAMES);
359
427
  });
360
428
 
361
429
  test("uses the Nolo chat proxy for platform agents even when direct provider env exists", async () => {
@@ -369,7 +437,7 @@ describe("CLI local runtime adapter", () => {
369
437
  provider: "fireworks",
370
438
  apiSource: "platform",
371
439
  useServerProxy: true,
372
- tools: ["gitStatus"],
440
+ tools: ["readWorkspaceFile"],
373
441
  }],
374
442
  ]);
375
443
  const adapter = createCliLocalRuntimeAdapter({
@@ -419,6 +487,7 @@ describe("CLI local runtime adapter", () => {
419
487
  body: {
420
488
  provider: "fireworks",
421
489
  apiSource: "platform",
490
+ agentKey: "agent-user-1-frontend",
422
491
  },
423
492
  });
424
493
  });
@@ -573,6 +642,12 @@ describe("CLI local runtime adapter", () => {
573
642
  cwd: import.meta.dir,
574
643
  now: () => 1710000000000,
575
644
  createId: () => "01TRACE",
645
+ localToolExecutors: {
646
+ execShell: async () => ({
647
+ content: "stdout:\ntrace-ok\n\nexitCode: 0",
648
+ metadata: { exitCode: 0, timedOut: false },
649
+ }),
650
+ },
576
651
  fetchImpl: async (_url, init) => {
577
652
  const body = JSON.parse(String(init?.body));
578
653
  const hasToolResult = body.messages.some((message: any) => message.role === "tool");
@@ -586,7 +661,11 @@ describe("CLI local runtime adapter", () => {
586
661
  type: "function",
587
662
  function: {
588
663
  name: "execShell",
589
- 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
+ }),
590
669
  },
591
670
  }],
592
671
  },
@@ -951,7 +1030,7 @@ describe("CLI local runtime adapter", () => {
951
1030
  await adapter.loadAgentConfig("default-tools");
952
1031
  await expect(adapter.executeTool({
953
1032
  id: "call-read",
954
- name: "readWorkspaceFile",
1033
+ name: "readFile",
955
1034
  arguments: JSON.stringify({ path: "README.md" }),
956
1035
  })).resolves.toMatchObject({
957
1036
  content: "local ok\n",
@@ -961,6 +1040,91 @@ describe("CLI local runtime adapter", () => {
961
1040
  }
962
1041
  });
963
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
+
964
1128
  test("activates an agent-declared task worktree before running workspace tools", async () => {
965
1129
  const taskWorktreeRoot = mkdtempSync(join(tmpdir(), "nolo-cli-runtime-worktree-"));
966
1130
  const chunks: string[] = [];
@@ -992,7 +1156,7 @@ describe("CLI local runtime adapter", () => {
992
1156
  await adapter.loadAgentConfig("frontend");
993
1157
  const result = await adapter.executeTool({
994
1158
  id: "call-read",
995
- name: "readWorkspaceFile",
1159
+ name: "readFile",
996
1160
  arguments: JSON.stringify({ path: "README.md" }),
997
1161
  });
998
1162
 
@@ -1017,6 +1181,12 @@ describe("CLI local runtime adapter", () => {
1017
1181
  iterator: () => (async function* () {})(),
1018
1182
  },
1019
1183
  cwd: import.meta.dir,
1184
+ localToolExecutors: {
1185
+ execShell: async () => ({
1186
+ content: import.meta.dir,
1187
+ metadata: { exitCode: 0, timedOut: false },
1188
+ }),
1189
+ },
1020
1190
  fetchImpl: async () => Response.json({}),
1021
1191
  });
1022
1192
 
@@ -1068,7 +1238,7 @@ describe("CLI local runtime adapter", () => {
1068
1238
  }),
1069
1239
  });
1070
1240
 
1071
- expect(result.content).toBe("wrote src/app.ts");
1241
+ expect(result.content).toBe(`wrote ${join("src", "app.ts")}`);
1072
1242
  expect(readFileSync(join(workspaceRoot, "src/app.ts"), "utf8")).toBe("export const cliValue = 1;\n");
1073
1243
  } finally {
1074
1244
  rmSync(workspaceRoot, { recursive: true, force: true });