nolo-cli 0.1.37 → 0.1.39

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 (52) hide show
  1. package/agent-runtime/agentRecordConfig.ts +0 -14
  2. package/agent-runtime/hostAdapter.ts +0 -1
  3. package/agent-runtime/index.ts +24 -18
  4. package/agent-runtime/localDialogRead.ts +23 -0
  5. package/agent-runtime/localToolPolicy.ts +2 -7
  6. package/agent-runtime/localWorkspaceTools.ts +9 -0
  7. package/agent-runtime/noloWorkspaceTools.ts +371 -0
  8. package/agent-runtime/runtimeToolPolicy.ts +1 -3
  9. package/agent-runtime/types.ts +1 -1
  10. package/agentAliases.ts +6 -0
  11. package/agentRunCommand.ts +57 -65
  12. package/agentRuntimeLocal.ts +0 -7
  13. package/chat/messages/fetchMessages.ts +84 -0
  14. package/chat/messages/types.ts +185 -0
  15. package/cli/agentAliases.ts +6 -0
  16. package/cli/agentRunCommand.ts +57 -65
  17. package/cli/agentRuntimeLocal.ts +0 -7
  18. package/cli/cliEnvHelpers.ts +11 -7
  19. package/cli/client/agentRun.ts +123 -24
  20. package/cli/client/localRuntimeAdapter.ts +87 -59
  21. package/cli/connectorRunArtifact.ts +0 -1
  22. package/cli/dialogCommands.ts +572 -0
  23. package/cli/dialogInternalCommandEntries.ts +4 -0
  24. package/cli/machineWsRunDispatch.ts +29 -37
  25. package/cli/scriptCommandEntries.ts +0 -2
  26. package/cli/tui/readlineWorkspace.ts +69 -0
  27. package/cli/tui/session.ts +176 -1
  28. package/cliEnvHelpers.ts +11 -7
  29. package/client/agentConfigResolver.test.ts +2 -3
  30. package/client/agentRun.test.ts +185 -1
  31. package/client/agentRun.ts +123 -24
  32. package/client/localRuntimeAdapter.test.ts +90 -25
  33. package/client/localRuntimeAdapter.ts +87 -59
  34. package/connectorRunArtifact.ts +0 -1
  35. package/database/server/db.ts +99 -0
  36. package/database/server/ensureDbOpen.ts +12 -0
  37. package/database/server/memoryAuthorityStore.ts +133 -0
  38. package/database/server/serverStoreFactory.ts +118 -0
  39. package/dialogCommands.ts +572 -0
  40. package/dialogInternalCommandEntries.ts +4 -0
  41. package/machineWsRunDispatch.ts +29 -37
  42. package/package.json +10 -9
  43. package/scriptCommandEntries.ts +0 -2
  44. package/tui/readlineWorkspace.ts +69 -0
  45. package/tui/session.ts +176 -1
  46. package/agent-runtime/taskWorkspace.ts +0 -193
  47. package/agent-runtime/workspaceSession.ts +0 -76
  48. package/cli/client/taskWorktree.ts +0 -8
  49. package/cli/client/workspaceSession.ts +0 -11
  50. package/client/taskWorktree.ts +0 -8
  51. package/client/workspaceSession.test.ts +0 -57
  52. package/client/workspaceSession.ts +0 -11
@@ -6,7 +6,8 @@ import {
6
6
  findServerPlatformTools,
7
7
  runAgentTurn,
8
8
  } from "./agentRun";
9
- import { NOLO_PROJECT_MANAGER_AGENT_KEY } from "../agentAliases";
9
+ import { BUILTIN_NOLO_AGENT_KEY } from "./localRuntimeAdapter";
10
+ import { MIMO_MONTH_AGENT_KEY, NOLO_PROJECT_MANAGER_AGENT_KEY } from "../agentAliases";
10
11
 
11
12
  class CaptureOutput extends Writable {
12
13
  chunks: string[] = [];
@@ -93,6 +94,34 @@ describe("cli agent run client", () => {
93
94
  expect(output.text()).not.toContain("tokens=");
94
95
  });
95
96
 
97
+ test("prints agent run error details from failed HTTP responses", async () => {
98
+ const output = new CaptureOutput();
99
+
100
+ const result = await runAgentTurn({
101
+ agentName: "win-codex",
102
+ agentKey: "agent-win-codex",
103
+ serverUrl: "https://nolo.chat",
104
+ message: "restart connector",
105
+ scriptDir: "C:/missing/scripts",
106
+ env: { AUTH_TOKEN: "token-123" },
107
+ output,
108
+ fetchImpl: async () =>
109
+ Response.json({
110
+ error: "Connector execution failed",
111
+ reason: "connector_execution_failed",
112
+ code: "connector_disconnected_mid_run",
113
+ message: "Connector disconnected for machine: machine-win",
114
+ }, { status: 502 }),
115
+ });
116
+
117
+ expect(result).toEqual({ exitCode: 1 });
118
+ expect(output.text()).toContain("[nolo] Agent request failed: HTTP 502");
119
+ expect(output.text()).toContain("Connector execution failed");
120
+ expect(output.text()).toContain("Connector disconnected for machine: machine-win");
121
+ expect(output.text()).toContain("code=connector_disconnected_mid_run");
122
+ expect(output.text()).toContain("reason=connector_execution_failed");
123
+ });
124
+
96
125
  test("prepends task-run context for HTTP agent runs", async () => {
97
126
  const output = new CaptureOutput();
98
127
  const requests: Array<{ body: any }> = [];
@@ -791,6 +820,161 @@ describe("cli agent run client", () => {
791
820
  expect(output.text()).toContain("pm > server ok");
792
821
  });
793
822
 
823
+ test("auto mode does not skip monthly Mimo when a local shell cwd is already bound", async () => {
824
+ const output = new CaptureOutput();
825
+ const httpCalls: string[] = [];
826
+ let providerCalled = false;
827
+
828
+ const result = await runAgentTurn({
829
+ agentName: MIMO_MONTH_AGENT_KEY,
830
+ agentKey: MIMO_MONTH_AGENT_KEY,
831
+ serverUrl: "https://us.nolo.chat",
832
+ message: "fix tests and commit",
833
+ scriptDir: "C:/missing/scripts",
834
+ env: {
835
+ AUTH_TOKEN: "token-123",
836
+ NOLO_LOCAL_SHELL_MODE: "worktree",
837
+ },
838
+ output,
839
+ runtimeMode: "auto",
840
+ localRuntimeCwd: "/repo/.worktrees/nolo-agent-mimo",
841
+ localRuntimeAdapter: {
842
+ host: "cli",
843
+ capabilities: ["leveldb-agent-config", "local-provider", "local-tools"],
844
+ loadAgentConfig: async (agentRef) => ({
845
+ key: agentRef,
846
+ name: "包月 Mimo",
847
+ prompt: "Implement backend tasks.",
848
+ provider: "custom",
849
+ model: "mimo-v2.5-pro",
850
+ toolNames: ["queryTableRows"],
851
+ }),
852
+ loadDialogHistory: async () => [],
853
+ saveTurn: async () => ({ dialogId: "dialog-mimo-local" }),
854
+ resolveProvider: async () => ({
855
+ model: "mimo-v2.5-pro",
856
+ complete: async () => {
857
+ providerCalled = true;
858
+ return { content: "local mimo ok", model: "mimo-v2.5-pro" };
859
+ },
860
+ }),
861
+ executeTool: async () => ({ content: "ok" }),
862
+ },
863
+ fetchImpl: async (url) => {
864
+ httpCalls.push(String(url));
865
+ return Response.json({ content: "server" });
866
+ },
867
+ });
868
+
869
+ expect(providerCalled).toBe(true);
870
+ expect(result).toEqual({ exitCode: 0, dialogId: "dialog-mimo-local" });
871
+ expect(httpCalls).toEqual([]);
872
+ expect(output.text()).not.toContain("skipping local runtime");
873
+ expect(output.text()).toContain(`${MIMO_MONTH_AGENT_KEY} -> working locally`);
874
+ });
875
+
876
+ test("auto mode skips local runtime for machine-bound localhost custom providers", async () => {
877
+ const output = new CaptureOutput();
878
+ const httpCalls: Array<{ url: string; body: any }> = [];
879
+
880
+ const result = await runAgentTurn({
881
+ agentName: "win-qwen",
882
+ agentKey: "agent-win-qwen",
883
+ serverUrl: "https://nolo.chat",
884
+ message: "run on the bound Windows machine",
885
+ scriptDir: "C:/missing/scripts",
886
+ env: { AUTH_TOKEN: "token-123", OPENAI_API_KEY: "local-provider-present" },
887
+ output,
888
+ runtimeMode: "auto",
889
+ localRuntimeAdapter: {
890
+ host: "cli",
891
+ capabilities: ["leveldb-agent-config", "local-provider", "local-tools"],
892
+ loadAgentConfig: async (agentRef) => ({
893
+ key: agentRef,
894
+ name: "Win Qwen",
895
+ prompt: "Windows local model",
896
+ provider: "custom",
897
+ model: "qwen",
898
+ customProviderUrl: "http://127.0.0.1:8080/v1/chat/completions",
899
+ runtimeBinding: {
900
+ machineId: "machine-win",
901
+ ownerUserId: "user-1",
902
+ connectorSurface: "cli",
903
+ },
904
+ }),
905
+ loadDialogHistory: async () => [],
906
+ saveTurn: async () => {
907
+ throw new Error("local runtime should be skipped");
908
+ },
909
+ resolveProvider: async () => {
910
+ throw new Error("local provider should be skipped");
911
+ },
912
+ executeTool: async () => {
913
+ throw new Error("local tools should be skipped");
914
+ },
915
+ },
916
+ fetchImpl: async (url, init) => {
917
+ httpCalls.push({ url: String(url), body: JSON.parse(String(init?.body)) });
918
+ return Response.json({ content: "server connector ok", dialogId: "dialog-win" });
919
+ },
920
+ });
921
+
922
+ expect(result).toEqual({ exitCode: 0, dialogId: "dialog-win" });
923
+ expect(httpCalls).toHaveLength(1);
924
+ expect(httpCalls[0]?.body.agentKey).toBe("agent-win-qwen");
925
+ expect(output.text()).toContain("auto runtime: skipping local runtime");
926
+ expect(output.text()).toContain("machine-bound localhost custom provider");
927
+ expect(output.text()).not.toContain("win-qwen -> working locally");
928
+ });
929
+
930
+ test("auto mode keeps builtin nolo on local runtime so CLI workspace tools are available", async () => {
931
+ const output = new CaptureOutput();
932
+ const httpCalls: string[] = [];
933
+ let providerCalled = false;
934
+
935
+ const result = await runAgentTurn({
936
+ agentName: "nolo",
937
+ agentKey: BUILTIN_NOLO_AGENT_KEY,
938
+ serverUrl: "https://nolo.chat",
939
+ message: "帮我总结最近 10 个对话",
940
+ scriptDir: "C:/missing/scripts",
941
+ env: { AUTH_TOKEN: "token-123" },
942
+ output,
943
+ runtimeMode: "auto",
944
+ localRuntimeAdapter: {
945
+ host: "cli",
946
+ capabilities: ["leveldb-agent-config", "local-provider", "local-tools"],
947
+ loadAgentConfig: async () => ({
948
+ key: BUILTIN_NOLO_AGENT_KEY,
949
+ name: "nolo",
950
+ prompt: "Use workspace tools.",
951
+ model: "fake-local",
952
+ toolNames: ["queryTableRows", "addTableRow", "updateTableRow"],
953
+ }),
954
+ loadDialogHistory: async () => [],
955
+ saveTurn: async () => ({ dialogId: "dialog-builtin-nolo-local" }),
956
+ resolveProvider: async () => ({
957
+ model: "fake-local",
958
+ complete: async () => {
959
+ providerCalled = true;
960
+ return { content: "local nolo with tools", model: "fake-local" };
961
+ },
962
+ }),
963
+ executeTool: async () => ({ content: "[]" }),
964
+ },
965
+ fetchImpl: async (url) => {
966
+ httpCalls.push(String(url));
967
+ return Response.json({ content: "server" });
968
+ },
969
+ });
970
+
971
+ expect(providerCalled).toBe(true);
972
+ expect(result).toEqual({ exitCode: 0, dialogId: "dialog-builtin-nolo-local" });
973
+ expect(httpCalls).toEqual([]);
974
+ expect(output.text()).not.toContain("skipping local runtime");
975
+ expect(output.text()).toContain("nolo -> working locally");
976
+ });
977
+
794
978
  test("auto mode skips local runtime for server platform tools declared by runtime policy", async () => {
795
979
  const output = new CaptureOutput();
796
980
  const httpCalls: Array<{ url: string; body: any }> = [];
@@ -9,7 +9,7 @@ import {
9
9
  } from "../agentAliases";
10
10
  import type { LocalAgentToolEvent } from "../agent-runtime/localLoop";
11
11
  import type { AgentRuntimeHostAdapter, AgentRuntimeRequestedMode } from "../agentRuntimeLocal";
12
- import { createCliLocalRuntimeAdapter } from "./localRuntimeAdapter";
12
+ import { createCliLocalRuntimeAdapter, isBuiltinNoloAgentRef } from "./localRuntimeAdapter";
13
13
  import { createStreamingTextWriter } from "./streamingOutput";
14
14
  import { prependTaskRunPrompt, type TaskRunPromptContext } from "./taskRunPrompt";
15
15
 
@@ -73,6 +73,47 @@ export type RunAgentTurnResult = {
73
73
  streamInterrupted?: boolean;
74
74
  };
75
75
 
76
+ class Spinner {
77
+ private timer: any = null;
78
+ private frameIndex = 0;
79
+ private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
80
+ private isTTY: boolean;
81
+
82
+ constructor(
83
+ private output: OutputLike,
84
+ private text: string
85
+ ) {
86
+ this.isTTY = Boolean((output as any).isTTY ?? process.stdout.isTTY);
87
+ }
88
+
89
+ start() {
90
+ if (!this.isTTY) {
91
+ this.output.write(`\n${this.text}...\n`);
92
+ return;
93
+ }
94
+
95
+ this.frameIndex = 0;
96
+ this.output.write(`\x1b[36m${this.frames[0]}\x1b[39m ${this.text}...`);
97
+
98
+ this.timer = setInterval(() => {
99
+ this.frameIndex = (this.frameIndex + 1) % this.frames.length;
100
+ this.output.write(`\r\x1b[36m${this.frames[this.frameIndex]}\x1b[39m ${this.text}...`);
101
+ }, 80);
102
+ }
103
+
104
+ stop() {
105
+ if (this.timer) {
106
+ clearInterval(this.timer);
107
+ this.timer = null;
108
+ }
109
+
110
+ if (this.isTTY) {
111
+ this.output.write("\r\x1b[K");
112
+ }
113
+ }
114
+ }
115
+
116
+
76
117
  const SERVER_PLATFORM_TOOL_NAMES = new Set([
77
118
  "addTableRow",
78
119
  "addTableRows",
@@ -139,10 +180,27 @@ function isKnownServerPlatformAgent(options: RunAgentTurnOptions) {
139
180
  const normalizedKey = normalizeAgentRef(options.agentKey);
140
181
  return Boolean(normalizedKey && KNOWN_SERVER_PLATFORM_AGENT_ALIASES.has(normalizedKey));
141
182
  }
142
-
143
- function resolveAuthToken(env: EnvLike) {
144
- return env.AUTH_TOKEN || env.AUTH || env.BENCHMARK_AUTH_TOKEN || "";
145
- }
183
+
184
+ function isMachineBoundLocalhostCustomProvider(agentConfig: any) {
185
+ const machineId =
186
+ agentConfig?.runtimeBinding && typeof agentConfig.runtimeBinding === "object"
187
+ ? String(agentConfig.runtimeBinding.machineId ?? "").trim()
188
+ : "";
189
+ const providerUrl = typeof agentConfig?.customProviderUrl === "string"
190
+ ? agentConfig.customProviderUrl.trim()
191
+ : "";
192
+ if (!machineId || !providerUrl) return false;
193
+ try {
194
+ const hostname = new URL(providerUrl).hostname.toLowerCase();
195
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+
201
+ function resolveAuthToken(env: EnvLike) {
202
+ return env.AUTH_TOKEN || env.AUTH || env.BENCHMARK_AUTH_TOKEN || "";
203
+ }
146
204
 
147
205
  function shouldShowUsage(env: EnvLike) {
148
206
  return env.NOLO_DEBUG === "1" || env.NOLO_SHOW_USAGE === "1";
@@ -173,6 +231,10 @@ function resolveLocalRuntimeAdapter(options: RunAgentTurnOptions) {
173
231
  }
174
232
 
175
233
  async function shouldSkipAutoLocalForServerPlatformTools(options: RunAgentTurnOptions) {
234
+ if (isBuiltinNoloAgentRef(options.agentKey)) return false;
235
+ if (options.localRuntimeCwd && options.env.NOLO_LOCAL_SHELL_MODE === "worktree") {
236
+ return false;
237
+ }
176
238
  if (isKnownServerPlatformAgent(options)) {
177
239
  options.output.write(
178
240
  `[nolo] auto runtime: skipping local runtime because ${options.agentKey} is a known platform agent. ` +
@@ -188,6 +250,13 @@ async function shouldSkipAutoLocalForServerPlatformTools(options: RunAgentTurnOp
188
250
  } catch {
189
251
  return false;
190
252
  }
253
+ if (isMachineBoundLocalhostCustomProvider(agentConfig)) {
254
+ options.output.write(
255
+ `[nolo] auto runtime: skipping local runtime because ${options.agentKey} is a machine-bound localhost custom provider. ` +
256
+ "Use --local explicitly to force the current machine.\n"
257
+ );
258
+ return true;
259
+ }
191
260
  const serverTools = resolveServerPlatformToolNames(agentConfig);
192
261
  if (serverTools.length === 0) return false;
193
262
  options.output.write(
@@ -288,6 +357,13 @@ function formatToolTraceEvent(event: LocalAgentToolEvent) {
288
357
 
289
358
  function shouldAttemptAutoLocal(options: RunAgentTurnOptions) {
290
359
  if (options.localRuntimeAdapter || options.localRuntimeAdapterFactory) return true;
360
+ if (
361
+ options.env.NOLO_DISABLE_CLI_WORKSPACE_TOOLS !== "1" &&
362
+ isBuiltinNoloAgentRef(options.agentKey) &&
363
+ resolveAuthToken(options.env)
364
+ ) {
365
+ return true;
366
+ }
291
367
  return Boolean(
292
368
  options.env.NOLO_LOCAL_OPENAI_API_KEY ||
293
369
  options.env.OPENAI_API_KEY ||
@@ -502,7 +578,8 @@ function buildTransportErrorHint(serverUrl: string, error: unknown) {
502
578
  }
503
579
 
504
580
  async function runHttpAgentTurn(options: RunAgentTurnOptions, authToken: string) {
505
- options.output.write(`\n${options.agentName} -> working...\n`);
581
+ const spinner = new Spinner(options.output, `${options.agentName} -> working`);
582
+ spinner.start();
506
583
 
507
584
  const fetchImpl = options.fetchImpl ?? fetch;
508
585
  let res: Response;
@@ -556,14 +633,16 @@ async function runHttpAgentTurn(options: RunAgentTurnOptions, authToken: string)
556
633
  ...(typeof options.timeoutMs === "number" ? { timeoutMs: options.timeoutMs } : {}),
557
634
  stream: !options.noStream && !options.background,
558
635
  }),
559
- });
560
- } catch (error) {
561
- options.output.write(buildTransportErrorHint(options.serverUrl, error));
562
- return { exitCode: 1 };
563
- }
564
-
636
+ });
637
+ } catch (error) {
638
+ spinner.stop();
639
+ options.output.write(buildTransportErrorHint(options.serverUrl, error));
640
+ return { exitCode: 1 };
641
+ }
642
+
565
643
  const contentType = res.headers.get("content-type") || "";
566
644
  if (contentType.includes("text/event-stream") && res.body) {
645
+ spinner.stop();
567
646
  const result = await readStreamingAgentRun(options, res);
568
647
  await recordTaskRunOutcome({
569
648
  options,
@@ -577,18 +656,32 @@ async function runHttpAgentTurn(options: RunAgentTurnOptions, authToken: string)
577
656
  });
578
657
  return result;
579
658
  }
580
-
581
- let data: any = {};
582
- try {
583
- data = await res.json();
584
- } catch {
585
- data = {};
586
- }
587
-
659
+
660
+ spinner.stop();
661
+ let data: any = {};
662
+ try {
663
+ data = await res.json();
664
+ } catch {
665
+ data = {};
666
+ }
667
+
588
668
  if (!res.ok) {
589
669
  options.output.write(`[nolo] Agent request failed: HTTP ${res.status}\n`);
590
- if (data?.error || data?.message) {
591
- options.output.write(`${data.error || data.message}\n`);
670
+ const errorText = typeof data?.error === "string" ? data.error.trim() : "";
671
+ const messageText = typeof data?.message === "string" ? data.message.trim() : "";
672
+ const reasonText = typeof data?.reason === "string" ? data.reason.trim() : "";
673
+ const codeText = typeof data?.code === "string" ? data.code.trim() : "";
674
+ if (errorText || messageText) {
675
+ options.output.write(`${errorText || messageText}\n`);
676
+ if (messageText && messageText !== errorText) {
677
+ options.output.write(`${messageText}\n`);
678
+ }
679
+ if (codeText && codeText !== errorText && codeText !== messageText) {
680
+ options.output.write(`code=${codeText}\n`);
681
+ }
682
+ if (reasonText && reasonText !== errorText && reasonText !== messageText) {
683
+ options.output.write(`reason=${reasonText}\n`);
684
+ }
592
685
  }
593
686
  await recordTaskRunOutcome({
594
687
  options,
@@ -691,7 +784,8 @@ async function runLocalAgentTurnForCli(
691
784
  return { exitCode: 1 };
692
785
  }
693
786
 
694
- options.output.write(`\n${options.agentName} -> working locally...\n`);
787
+ const spinner = new Spinner(options.output, `${options.agentName} -> working locally`);
788
+ spinner.start();
695
789
  const toolEvents: LocalAgentToolEvent[] = [];
696
790
  try {
697
791
  const traceLocalTools = shouldTraceLocalTools(options);
@@ -716,11 +810,15 @@ async function runLocalAgentTurnForCli(
716
810
  ? {
717
811
  onToolEvent: (event) => {
718
812
  if (collectToolEvidence) toolEvents.push(event);
719
- if (traceLocalTools) options.output.write(formatToolTraceEvent(event));
813
+ if (traceLocalTools) {
814
+ spinner.stop();
815
+ options.output.write(formatToolTraceEvent(event));
816
+ }
720
817
  },
721
818
  }
722
819
  : {}),
723
820
  });
821
+ spinner.stop();
724
822
  const content = result.content.trim();
725
823
  if (content) {
726
824
  options.output.write(`\n${options.agentName} > ${content}\n`);
@@ -736,6 +834,7 @@ async function runLocalAgentTurnForCli(
736
834
  });
737
835
  return { exitCode: 0, dialogId: result.dialogId };
738
836
  } catch (error) {
837
+ spinner.stop();
739
838
  if (settings.reportFailure) {
740
839
  options.output.write(
741
840
  `[nolo] Local agent run failed: ${
@@ -1043,17 +1043,20 @@ describe("CLI local runtime adapter", () => {
1043
1043
  });
1044
1044
  });
1045
1045
 
1046
- test("rejects tools unless the local tool policy allows and registers them", async () => {
1046
+ test("requires explicit shell mode for registered execShell", async () => {
1047
1047
  const adapter = createCliLocalRuntimeAdapter({
1048
1048
  env: {},
1049
+ localToolExecutors: {
1050
+ execShell: async (call) => ({ content: `shell:${call.arguments}` }),
1051
+ },
1049
1052
  fetchImpl: async () => Response.json({}),
1050
1053
  });
1051
1054
 
1052
1055
  await expect(adapter.executeTool({
1053
1056
  id: "call-1",
1054
1057
  name: "execShell",
1055
- arguments: "{}",
1056
- })).rejects.toThrow("execShell requires NOLO_LOCAL_SHELL_MODE");
1058
+ arguments: "{\"cmd\":\"pwd\"}",
1059
+ })).rejects.toThrow("execShell requires NOLO_LOCAL_SHELL_MODE=worktree");
1057
1060
  });
1058
1061
 
1059
1062
  test("executes explicitly allowed registered local tools declared by the agent", async () => {
@@ -1095,7 +1098,7 @@ describe("CLI local runtime adapter", () => {
1095
1098
  expect(result.content).toContain("README.md");
1096
1099
  });
1097
1100
 
1098
- test("advertises execShell to OpenAI-compatible providers when shell mode is enabled", async () => {
1101
+ test("advertises execShell to OpenAI-compatible providers only in explicit shell mode", async () => {
1099
1102
  const requests: Array<{ body: any }> = [];
1100
1103
  const adapter = createCliLocalRuntimeAdapter({
1101
1104
  env: {
@@ -1248,46 +1251,108 @@ describe("CLI local runtime adapter", () => {
1248
1251
  expect(result.metadata).toMatchObject({ serverPlatformTool: true });
1249
1252
  });
1250
1253
 
1251
- test("activates an agent-declared task worktree before running workspace tools", async () => {
1252
- const taskWorktreeRoot = mkdtempSync(join(tmpdir(), "nolo-cli-runtime-worktree-"));
1253
- const chunks: string[] = [];
1254
+ test("adds typed CLI workspace tools to the default nolo local agent request", async () => {
1255
+ const requests: Array<{ body: any }> = [];
1256
+ const adapter = createCliLocalRuntimeAdapter({
1257
+ env: {
1258
+ OPENAI_API_KEY: "sk-local",
1259
+ NOLO_LOCAL_OPENAI_BASE_URL: "http://127.0.0.1:11434/v1",
1260
+ },
1261
+ db: {
1262
+ get: async () => ({
1263
+ dbKey: "agent-pub-01NOLOAPPBLD000000019KCKT0",
1264
+ id: "01NOLOAPPBLD000000019KCKT0",
1265
+ name: "nolo",
1266
+ prompt: "Route through typed tools.",
1267
+ model: "gpt-4.1-mini",
1268
+ tools: ["fetchWebpage"],
1269
+ }),
1270
+ put: async () => {},
1271
+ batch: async () => {},
1272
+ iterator: () => (async function* () {})(),
1273
+ },
1274
+ fetchImpl: async (_url, init) => {
1275
+ requests.push({ body: JSON.parse(String(init?.body)) });
1276
+ return Response.json({
1277
+ choices: [{ message: { content: "done" } }],
1278
+ });
1279
+ },
1280
+ });
1281
+
1282
+ await runLocalAgentTurn({
1283
+ adapter,
1284
+ agentRef: "agent-pub-01NOLOAPPBLD000000019KCKT0",
1285
+ input: "帮我总结最近 10 个对话",
1286
+ });
1287
+
1288
+ const toolNames = toolNamesFromRequest(requests[0]);
1289
+ expect(toolNames).toContain("listDialogs");
1290
+ expect(toolNames).toContain("readDialog");
1291
+ expect(toolNames).toContain("listAgents");
1292
+ expect(toolNames).toContain("readSpace");
1293
+ expect(toolNames).toContain("queryTableRows");
1294
+ });
1295
+
1296
+ test("executes typed CLI workspace tools through whitelisted nolo commands", async () => {
1297
+ const spawnCalls: Array<{ cmd: string[]; env: NodeJS.ProcessEnv }> = [];
1298
+ const originalSpawn = Bun.spawn;
1299
+ Bun.spawn = ((options: { cmd: string[]; env: NodeJS.ProcessEnv }) => {
1300
+ spawnCalls.push(options);
1301
+ return {
1302
+ stdout: new ReadableStream({
1303
+ start(controller) {
1304
+ controller.enqueue(new TextEncoder().encode("dialog output\n"));
1305
+ controller.close();
1306
+ },
1307
+ }),
1308
+ stderr: new ReadableStream({
1309
+ start(controller) {
1310
+ controller.close();
1311
+ },
1312
+ }),
1313
+ exited: Promise.resolve(0),
1314
+ };
1315
+ }) as unknown as typeof Bun.spawn;
1316
+
1254
1317
  try {
1255
- await Bun.write(join(taskWorktreeRoot, "README.md"), "worktree ok\n");
1256
1318
  const adapter = createCliLocalRuntimeAdapter({
1257
1319
  env: {
1258
1320
  NOLO_LOCAL_USER_ID: "user-1",
1321
+ AUTH_TOKEN: "token-1",
1259
1322
  },
1260
- output: { write: (chunk) => chunks.push(chunk) },
1261
1323
  db: {
1262
1324
  get: async () => ({
1263
- dbKey: "agent-user-1-frontend",
1264
- id: "frontend",
1265
- prompt: "Use local workspace tools.",
1266
- runtimeBinding: { localWorkspaceMode: "task-worktree" },
1325
+ dbKey: "agent-pub-01NOLOAPPBLD000000019KCKT0",
1326
+ id: "01NOLOAPPBLD000000019KCKT0",
1267
1327
  }),
1268
1328
  put: async () => {},
1269
1329
  batch: async () => {},
1270
1330
  iterator: () => (async function* () {})(),
1271
1331
  },
1272
- prepareTaskWorktree: async ({ agentKey }) => ({
1273
- path: taskWorktreeRoot,
1274
- branchName: `nolo-agent-${agentKey}`,
1275
- }),
1276
1332
  fetchImpl: async () => Response.json({}),
1277
1333
  });
1278
1334
 
1279
- await adapter.loadAgentConfig("frontend");
1335
+ await adapter.loadAgentConfig("agent-pub-01NOLOAPPBLD000000019KCKT0");
1280
1336
  const result = await adapter.executeTool({
1281
- id: "call-read",
1282
- name: "readFile",
1283
- arguments: JSON.stringify({ path: "README.md" }),
1337
+ id: "call-list-dialogs",
1338
+ name: "listDialogs",
1339
+ arguments: JSON.stringify({ limit: 3 }),
1284
1340
  });
1285
1341
 
1286
- expect(result.content).toBe("worktree ok\n");
1287
- expect(chunks.join("")).toContain(`workspace session: task-worktree ${taskWorktreeRoot}`);
1288
- expect(chunks.join("")).toContain("branch nolo-agent-frontend");
1342
+ expect(result.content).toBe("dialog output\n");
1343
+ expect(result.metadata).toMatchObject({
1344
+ cliWorkspaceTool: true,
1345
+ exitCode: 0,
1346
+ });
1347
+ expect(spawnCalls[0]?.cmd.at(-5)?.endsWith("packages/cli/index.ts")).toBe(true);
1348
+ expect(spawnCalls[0]?.cmd.slice(-4)).toEqual([
1349
+ "dialog",
1350
+ "list",
1351
+ "--limit",
1352
+ "3",
1353
+ ]);
1289
1354
  } finally {
1290
- rmSync(taskWorktreeRoot, { recursive: true, force: true });
1355
+ Bun.spawn = originalSpawn;
1291
1356
  }
1292
1357
  });
1293
1358