nolo-cli 0.1.21 → 0.1.23

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 (72) hide show
  1. package/agent-runtime/agentRecordConfig.ts +4 -0
  2. package/agent-runtime/hostAdapter.ts +2 -0
  3. package/agent-runtime/index.ts +7 -0
  4. package/agent-runtime/localLoop.ts +2 -0
  5. package/agent-runtime/platformChatProvider.ts +3 -0
  6. package/agent-runtime/runtimeToolPolicy.ts +92 -0
  7. package/agent-runtime/types.ts +42 -0
  8. package/agentRunCommand.ts +74 -1
  9. package/agentRuntimeCommands.ts +17 -89
  10. package/ai/agent/streamAgentChatTurn.ts +104 -20
  11. package/ai/chat/fetchUtils.native.ts +2 -0
  12. package/ai/chat/fetchUtils.ts +2 -0
  13. package/ai/chat/sendOpenAICompletionsRequest.ts +56 -0
  14. package/ai/chat/sendOpenAIResponseRequest.ts +64 -0
  15. package/ai/llm/kimi.ts +1 -1
  16. package/ai/llm/providers.ts +3 -0
  17. package/ai/llm/reasoningModels.ts +1 -0
  18. package/ai/skills/skillDocProtocol.ts +95 -3
  19. package/ai/taskRun/taskRunProtocol.ts +1 -0
  20. package/ai/tools/agent/agentTools.ts +17 -0
  21. package/ai/tools/agent/startAgentDialogTool.ts +53 -0
  22. package/ai/tools/modelUsageTools.ts +5 -0
  23. package/client/agentRun.test.ts +257 -7
  24. package/client/agentRun.ts +133 -34
  25. package/client/localRuntimeAdapter.test.ts +2 -0
  26. package/client/localRuntimeAdapter.ts +15 -2
  27. package/database/actions/common.ts +4 -3
  28. package/database/config.ts +19 -0
  29. package/machineCommands.ts +400 -45
  30. package/package.json +4 -2
  31. package/render/canvas/canvasEditContext.ts +127 -0
  32. package/render/canvas/canvasRuntime.ts +57 -0
  33. package/render/canvas/canvasSnapshotParser.ts +76 -0
  34. package/render/canvas/canvasTree.ts +308 -0
  35. package/render/canvas/types.ts +46 -0
  36. package/render/layout/deleteBehavior.ts +52 -0
  37. package/render/layout/mainLayoutSidebar.ts +17 -0
  38. package/render/layout/mainLayoutViewMode.ts +56 -0
  39. package/render/layout/topbarUtils.ts +87 -0
  40. package/render/layout/useDevReloadPending.ts +30 -0
  41. package/render/page/createPageAction.ts +183 -0
  42. package/render/page/docSlice.ts +468 -0
  43. package/render/page/server/createPage.ts +174 -0
  44. package/render/page/server/handleCreatePage.ts +91 -0
  45. package/render/page/server/index.ts +4 -0
  46. package/render/page/types.ts +17 -0
  47. package/render/page/useKeyboardSave.ts +48 -0
  48. package/render/styles/zIndex.ts +12 -0
  49. package/render/surf/WeatherIconStyles.ts +17 -0
  50. package/render/surf/color.ts +9 -0
  51. package/render/surf/config.ts +46 -0
  52. package/render/surf/screens/style.ts +1 -0
  53. package/render/surf/styles/ToggleButtonStyles.ts +8 -0
  54. package/render/surf/utils/groupedWeatherData.ts +32 -0
  55. package/render/surf/weatherUtils.ts +50 -0
  56. package/render/table/activityColumns.ts +6 -0
  57. package/render/table/createTableAction.ts +270 -0
  58. package/render/table/deleteTableAction.ts +129 -0
  59. package/render/table/fetchAndCacheTableRows.ts +174 -0
  60. package/render/table/tableSlice.ts +1106 -0
  61. package/render/table/tableView.ts +289 -0
  62. package/render/table/toolValueUtils.ts +363 -0
  63. package/render/table/types.ts +252 -0
  64. package/render/table/useCreateTable.ts +72 -0
  65. package/render/table/useTable.ts +61 -0
  66. package/render/table/utils/tableSerialization.ts +50 -0
  67. package/render/web/elements/artifactPreviewCode.ts +43 -0
  68. package/render/web/elements/artifactRuntimePreload.ts +52 -0
  69. package/render/web/elements/codeBlockAutoPreview.ts +10 -0
  70. package/render/web/elements/mermaidPreview.ts +21 -0
  71. package/render/web/ui/useInlineEdit.ts +135 -0
  72. package/tableCommands.ts +42 -5
@@ -47,12 +47,30 @@ export interface SkillEvalConfig {
47
47
  cases: SkillEvalCase[];
48
48
  }
49
49
 
50
+ export interface WorkflowReferenceConfig {
51
+ version: "0.1";
52
+ kind: "workflow";
53
+ id?: string;
54
+ name: string;
55
+ description: string;
56
+ defaultAgent?: string;
57
+ inputs?: string[];
58
+ recommendedTools?: string[];
59
+ requiredTools?: string[];
60
+ requiredOutputs?: string[];
61
+ gates?: string[];
62
+ budgetTier?: SkillBudgetTier;
63
+ contextStrategy?: string;
64
+ failureProtocol?: string;
65
+ }
66
+
50
67
  export interface PageSkillMetadata {
51
68
  kind?: SkillDocKind;
52
69
  requiredSkills?: string[];
53
70
  recommendedSkills?: string[];
54
71
  skillConfig?: SkillDocConfig;
55
72
  evalConfig?: SkillEvalConfig;
73
+ workflowConfig?: WorkflowReferenceConfig;
56
74
  }
57
75
 
58
76
  export interface ParsedSkillDocProtocol {
@@ -71,6 +89,7 @@ export interface ParsedExternalSkillMarkdown {
71
89
 
72
90
  const SKILL_CONFIG_BLOCK = "skill-config";
73
91
  const EVAL_CONFIG_BLOCK = "eval-config";
92
+ const WORKFLOW_CONFIG_BLOCK = "workflow-config";
74
93
 
75
94
  const normalizeStringArray = (value: unknown): string[] | undefined => {
76
95
  if (!Array.isArray(value)) return undefined;
@@ -200,6 +219,49 @@ const normalizeEvalConfig = (value: unknown): SkillEvalConfig | undefined => {
200
219
  : undefined;
201
220
  };
202
221
 
222
+ const normalizeWorkflowConfig = (value: unknown): WorkflowReferenceConfig | undefined => {
223
+ if (!value || typeof value !== "object") return undefined;
224
+ const record = value as Record<string, unknown>;
225
+ const name =
226
+ typeof record.name === "string" && record.name.trim()
227
+ ? record.name.trim()
228
+ : "";
229
+ const description =
230
+ typeof record.description === "string" && record.description.trim()
231
+ ? record.description.trim()
232
+ : "";
233
+ if (!name || !description) return undefined;
234
+ const budgetTier = normalizeSkillEnumValue("budgetTier", record.budgetTier);
235
+ return {
236
+ version: "0.1",
237
+ kind: "workflow",
238
+ id:
239
+ typeof record.id === "string" && record.id.trim()
240
+ ? record.id.trim()
241
+ : undefined,
242
+ name,
243
+ description,
244
+ defaultAgent:
245
+ typeof record.defaultAgent === "string" && record.defaultAgent.trim()
246
+ ? record.defaultAgent.trim()
247
+ : undefined,
248
+ inputs: normalizeStringArray(record.inputs),
249
+ recommendedTools: normalizeStringArray(record.recommendedTools),
250
+ requiredTools: normalizeStringArray(record.requiredTools),
251
+ requiredOutputs: normalizeStringArray(record.requiredOutputs),
252
+ gates: normalizeStringArray(record.gates),
253
+ budgetTier,
254
+ contextStrategy:
255
+ typeof record.contextStrategy === "string" && record.contextStrategy.trim()
256
+ ? record.contextStrategy.trim()
257
+ : undefined,
258
+ failureProtocol:
259
+ typeof record.failureProtocol === "string" && record.failureProtocol.trim()
260
+ ? record.failureProtocol.trim()
261
+ : undefined,
262
+ };
263
+ };
264
+
203
265
  const normalizePageSkillMetadata = (
204
266
  value: unknown,
205
267
  fallbackTools?: string[]
@@ -214,17 +276,22 @@ const normalizePageSkillMetadata = (
214
276
  recommendedSkills: normalizeStringArray(record.recommendedSkills),
215
277
  skillConfig: normalizeSkillConfig(record.skillConfig, fallbackTools),
216
278
  evalConfig: normalizeEvalConfig(record.evalConfig),
279
+ workflowConfig: normalizeWorkflowConfig(record.workflowConfig),
217
280
  };
218
281
 
219
282
  if (meta.skillConfig && !meta.kind) {
220
283
  meta.kind = "skill";
221
284
  }
285
+ if (meta.workflowConfig && !meta.kind) {
286
+ meta.kind = "instruction";
287
+ }
222
288
 
223
289
  return meta.kind ||
224
290
  meta.requiredSkills ||
225
291
  meta.recommendedSkills ||
226
292
  meta.skillConfig ||
227
- meta.evalConfig
293
+ meta.evalConfig ||
294
+ meta.workflowConfig
228
295
  ? meta
229
296
  : undefined;
230
297
  };
@@ -268,9 +335,13 @@ export const parseSkillDocProtocol = (
268
335
  const source = typeof markdown === "string" ? markdown : "";
269
336
  const skillBlock = parseYamlObject(extractCommentBlock(source, SKILL_CONFIG_BLOCK));
270
337
  const evalBlock = parseYamlObject(extractCommentBlock(source, EVAL_CONFIG_BLOCK));
338
+ const workflowBlock = parseYamlObject(extractCommentBlock(source, WORKFLOW_CONFIG_BLOCK));
271
339
  const cleanedContent = removeCommentBlock(
272
- removeCommentBlock(source, SKILL_CONFIG_BLOCK),
273
- EVAL_CONFIG_BLOCK
340
+ removeCommentBlock(
341
+ removeCommentBlock(source, SKILL_CONFIG_BLOCK),
342
+ EVAL_CONFIG_BLOCK
343
+ ),
344
+ WORKFLOW_CONFIG_BLOCK
274
345
  )
275
346
  .replace(/\n{3,}/g, "\n\n")
276
347
  .trim();
@@ -292,6 +363,7 @@ export const parseSkillDocProtocol = (
292
363
  }
293
364
  : {}),
294
365
  ...(evalBlock ? { evalConfig: evalBlock } : {}),
366
+ ...(workflowBlock ? { workflowConfig: workflowBlock } : {}),
295
367
  },
296
368
  fallbackTools
297
369
  );
@@ -364,15 +436,35 @@ export const buildEvalConfigComment = (config: SkillEvalConfig): string =>
364
436
  cases: config.cases,
365
437
  })}\n-->`;
366
438
 
439
+ export const buildWorkflowConfigComment = (config: WorkflowReferenceConfig): string =>
440
+ `<!-- ${WORKFLOW_CONFIG_BLOCK}\n${yamlBlock({
441
+ version: config.version,
442
+ kind: config.kind,
443
+ ...(config.id ? { id: config.id } : {}),
444
+ name: config.name,
445
+ description: config.description,
446
+ ...(config.defaultAgent ? { defaultAgent: config.defaultAgent } : {}),
447
+ ...(config.inputs?.length ? { inputs: config.inputs } : {}),
448
+ ...(config.recommendedTools?.length ? { recommendedTools: config.recommendedTools } : {}),
449
+ ...(config.requiredTools?.length ? { requiredTools: config.requiredTools } : {}),
450
+ ...(config.requiredOutputs?.length ? { requiredOutputs: config.requiredOutputs } : {}),
451
+ ...(config.gates?.length ? { gates: config.gates } : {}),
452
+ ...(config.budgetTier ? { budgetTier: config.budgetTier } : {}),
453
+ ...(config.contextStrategy ? { contextStrategy: config.contextStrategy } : {}),
454
+ ...(config.failureProtocol ? { failureProtocol: config.failureProtocol } : {}),
455
+ })}\n-->`;
456
+
367
457
  export const buildSkillDocMarkdown = (options: {
368
458
  body?: string;
369
459
  skillConfig: SkillDocConfig;
370
460
  evalConfig?: SkillEvalConfig;
461
+ workflowConfig?: WorkflowReferenceConfig;
371
462
  }): string => {
372
463
  const sections = [
373
464
  options.body?.trim() || "",
374
465
  buildSkillConfigComment(options.skillConfig),
375
466
  options.evalConfig ? buildEvalConfigComment(options.evalConfig) : "",
467
+ options.workflowConfig ? buildWorkflowConfigComment(options.workflowConfig) : "",
376
468
  ].filter(Boolean);
377
469
  return sections.join("\n\n").trim();
378
470
  };
@@ -193,6 +193,7 @@ export type TaskRunControlPolicy = {
193
193
  autonomy?: TaskRunAutonomy;
194
194
  nextWakeAt?: string;
195
195
  pausedReason?: string;
196
+ runtimeToolPolicy?: Record<string, unknown>;
196
197
  budget?: {
197
198
  dailyUsdLimit?: number;
198
199
  modelBudget?: Record<string, TaskRunBudgetRule>;
@@ -28,6 +28,10 @@ import {
28
28
  streamParallelAgentsFunctionSchema,
29
29
  streamParallelAgentsFunc,
30
30
  } from "./streamParallelAgentsTool";
31
+ import {
32
+ startAgentDialogFunctionSchema,
33
+ startAgentDialogFunc,
34
+ } from "./startAgentDialogTool";
31
35
  import {
32
36
  createDialogFunctionSchema,
33
37
  createDialogFunc,
@@ -151,6 +155,19 @@ export const agentToolDefinitions: ToolDefinition[] = [
151
155
  behavior: "orchestrator",
152
156
  uiGroup: "agent",
153
157
  },
158
+ {
159
+ id: "startAgentDialog",
160
+ schema: startAgentDialogFunctionSchema,
161
+ executor: startAgentDialogFunc,
162
+ description: {
163
+ name: "startAgentDialog",
164
+ description:
165
+ "启动一个子 Agent dialog,返回 childDialogId,供调用方后续查询、接管或投影状态。",
166
+ category: "计划与编排",
167
+ },
168
+ behavior: "orchestrator",
169
+ uiGroup: "agent",
170
+ },
154
171
  {
155
172
  id: "createDialog",
156
173
  schema: createDialogFunctionSchema,
@@ -0,0 +1,53 @@
1
+ export const startAgentDialogFunctionSchema = {
2
+ name: "startAgentDialog",
3
+ description:
4
+ "启动一个子 Agent dialog 并立即返回 childDialogId。用于通用 agent 派发/交接,不等待子 Agent 完成,也不替调用方决定任务状态。",
5
+ parameters: {
6
+ type: "object",
7
+ properties: {
8
+ targetAgentKey: {
9
+ type: "string",
10
+ description: "要启动的目标 Agent key。",
11
+ },
12
+ message: {
13
+ type: "string",
14
+ description: "发送给目标 Agent 的自然语言任务或上下文。",
15
+ },
16
+ parentDialogId: {
17
+ type: "string",
18
+ description: "可选。父 dialogId;未提供时使用当前 dialogId。",
19
+ },
20
+ subjectRefs: {
21
+ type: "array",
22
+ description: "可选。与本次子 dialog 相关的业务对象引用,保持行业中立。",
23
+ items: {
24
+ type: "object",
25
+ properties: {
26
+ kind: { type: "string" },
27
+ id: { type: "string" },
28
+ role: { type: "string" },
29
+ },
30
+ required: ["kind", "id"],
31
+ },
32
+ },
33
+ idempotencyKey: {
34
+ type: "string",
35
+ description: "可选。调用方用于去重的稳定 key;当前版本仅透传给运行上下文。",
36
+ },
37
+ serverBase: {
38
+ type: "string",
39
+ description:
40
+ "可选。目标 Agent 所在的 nolo server origin;跨域目标必须由服务端 AGENT_TOOL_ALLOWED_SERVER_BASES 放行。",
41
+ },
42
+ timeoutMs: {
43
+ type: "number",
44
+ description: "可选。子 Agent background run 的执行超时预算(毫秒)。",
45
+ },
46
+ },
47
+ required: ["targetAgentKey", "message"],
48
+ },
49
+ };
50
+
51
+ export async function startAgentDialogFunc(): Promise<never> {
52
+ throw new Error("startAgentDialog is a server-side agent tool. Use it through /api/agent/run.");
53
+ }
@@ -179,6 +179,11 @@ export const notifyUserFunctionSchema = {
179
179
  type: "string",
180
180
  description: "可选跳转链接。",
181
181
  },
182
+ targetUserId: {
183
+ type: "string",
184
+ description:
185
+ "可选目标用户 ID。默认通知当前用户;只有受信任的系统 agent 或管理员可以通知其他用户。",
186
+ },
182
187
  },
183
188
  required: ["title", "message"],
184
189
  },
@@ -7,6 +7,7 @@ import {
7
7
  runAgentTurn,
8
8
  shouldUseScriptBridge,
9
9
  } from "./agentRun";
10
+ import { NOLO_PROJECT_MANAGER_AGENT_KEY } from "../agentAliases";
10
11
 
11
12
  class CaptureOutput extends Writable {
12
13
  chunks: string[] = [];
@@ -131,6 +132,12 @@ describe("cli agent run client", () => {
131
132
  expect(requests[0]?.body.userInput).toContain("rowDbKey: row-b2e06f801f-01TASK");
132
133
  expect(requests[0]?.body.userInput).toContain("workItemId: frontend-filter");
133
134
  expect(requests[0]?.body.userInput).toContain("User task:\nFix the filter UI");
135
+ expect(requests[0]?.body.runtimeContext.taskRun).toEqual({
136
+ rowDbKey: "row-b2e06f801f-01TASK",
137
+ taskRunId: "taskrun-1",
138
+ workItemId: "frontend-filter",
139
+ artifactIds: ["artifact-1"],
140
+ });
134
141
  });
135
142
 
136
143
  test("records task-run completion after an HTTP agent run returns a dialog", async () => {
@@ -242,6 +249,42 @@ describe("cli agent run client", () => {
242
249
  expect(result).toEqual({ exitCode: 0, dialogId: "dialog-1" });
243
250
  });
244
251
 
252
+ test("records background task-run handoff as running instead of completed", async () => {
253
+ const output = new CaptureOutput();
254
+ const writebacks: any[] = [];
255
+
256
+ const result = await runAgentTurn({
257
+ agentName: "frontend-implementer",
258
+ agentKey: "agent-frontend",
259
+ serverUrl: "https://nolo.chat",
260
+ message: "Fix settings style FOUC",
261
+ scriptDir: "C:/missing/scripts",
262
+ env: { AUTH_TOKEN: "token-123" },
263
+ runtimeMode: "server",
264
+ background: true,
265
+ noStream: true,
266
+ taskRunContext: {
267
+ rowDbKey: "row-b2e06f801f-01TASK",
268
+ workItemId: "self",
269
+ },
270
+ taskRunRecorder: async (args) => {
271
+ writebacks.push(args);
272
+ },
273
+ output,
274
+ scriptPathExists: () => false,
275
+ fetchImpl: async () => {
276
+ return Response.json({ dialogId: "dialog-bg", status: "pending" }, { status: 202 });
277
+ },
278
+ });
279
+
280
+ expect(result).toEqual({ exitCode: 0, dialogId: "dialog-bg" });
281
+ expect(writebacks).toHaveLength(1);
282
+ expect(writebacks[0]).toMatchObject({
283
+ status: "running",
284
+ dialogId: "dialog-bg",
285
+ });
286
+ });
287
+
245
288
  test("runs forced local turns through the injected runtime adapter without HTTP", async () => {
246
289
  const output = new CaptureOutput();
247
290
  const result = await runAgentTurn({
@@ -521,6 +564,58 @@ describe("cli agent run client", () => {
521
564
  });
522
565
  });
523
566
 
567
+ test("records local background task-run handoff as running instead of completed", async () => {
568
+ const output = new CaptureOutput();
569
+ const writebacks: any[] = [];
570
+
571
+ const result = await runAgentTurn({
572
+ agentName: "frontend",
573
+ agentKey: "frontend-local",
574
+ serverUrl: "https://nolo.chat",
575
+ message: "inspect repo in background",
576
+ scriptDir: "C:/missing/scripts",
577
+ env: { AUTH_TOKEN: "token-123" },
578
+ output,
579
+ runtimeMode: "local",
580
+ background: true,
581
+ taskRunContext: {
582
+ rowDbKey: "row-b2e06f801f-01TASK",
583
+ workItemId: "self",
584
+ },
585
+ taskRunRecorder: async (args) => {
586
+ writebacks.push(args);
587
+ },
588
+ localRuntimeAdapter: {
589
+ host: "cli",
590
+ capabilities: ["local-provider", "local-persistence"],
591
+ loadAgentConfig: async (agentRef) => ({
592
+ key: agentRef,
593
+ name: "Frontend",
594
+ prompt: "Fix UI",
595
+ model: "fake-local",
596
+ }),
597
+ loadDialogHistory: async () => [],
598
+ saveTurn: async () => ({ dialogId: "dialog-local-bg" }),
599
+ resolveProvider: async () => ({
600
+ model: "fake-local",
601
+ complete: async () => ({ content: "accepted locally", model: "fake-local" }),
602
+ }),
603
+ executeTool: async () => {
604
+ throw new Error("no tools expected");
605
+ },
606
+ },
607
+ scriptPathExists: () => false,
608
+ });
609
+
610
+ expect(result).toEqual({ exitCode: 0, dialogId: "dialog-local-bg" });
611
+ expect(writebacks).toHaveLength(1);
612
+ expect(writebacks[0]).toMatchObject({
613
+ status: "running",
614
+ dialogId: "dialog-local-bg",
615
+ });
616
+ expect(writebacks[0].resultSummary).toBeUndefined();
617
+ });
618
+
524
619
  test("auto mode prefers a working local runtime before HTTP", async () => {
525
620
  const output = new CaptureOutput();
526
621
  const httpCalls: string[] = [];
@@ -571,7 +666,7 @@ describe("cli agent run client", () => {
571
666
 
572
667
  const result = await runAgentTurn({
573
668
  agentName: "pm",
574
- agentKey: "nolo-project-manager",
669
+ agentKey: "agent-custom-platform-tools",
575
670
  serverUrl: "https://nolo.chat",
576
671
  message: "write task rows",
577
672
  scriptDir: "C:/missing/scripts",
@@ -608,13 +703,112 @@ describe("cli agent run client", () => {
608
703
 
609
704
  expect(result).toEqual({ exitCode: 0, dialogId: "dialog-server" });
610
705
  expect(httpCalls).toHaveLength(1);
611
- expect(httpCalls[0]?.body.agentKey).toBe("nolo-project-manager");
706
+ expect(httpCalls[0]?.body.agentKey).toBe("agent-custom-platform-tools");
612
707
  expect(output.text()).toContain("auto runtime: skipping local runtime");
613
708
  expect(output.text()).toContain("queryTableRows, addTableRow, updateTableRow");
614
709
  expect(output.text()).toContain("pm -> working");
615
710
  expect(output.text()).toContain("pm > server ok");
616
711
  });
617
712
 
713
+ test("auto mode skips local runtime for server platform tools declared by runtime policy", async () => {
714
+ const output = new CaptureOutput();
715
+ const httpCalls: Array<{ url: string; body: any }> = [];
716
+
717
+ const result = await runAgentTurn({
718
+ agentName: "pm",
719
+ agentKey: "agent-policy-platform-tools",
720
+ serverUrl: "https://nolo.chat",
721
+ message: "query task rows",
722
+ scriptDir: "C:/missing/scripts",
723
+ env: { AUTH_TOKEN: "token-123" },
724
+ output,
725
+ runtimeMode: "auto",
726
+ localRuntimeAdapter: {
727
+ host: "cli",
728
+ capabilities: ["leveldb-agent-config", "local-provider", "local-tools"],
729
+ loadAgentConfig: async (agentRef) => ({
730
+ key: agentRef,
731
+ name: "PM",
732
+ prompt: "Manage task rows",
733
+ model: "fake-local",
734
+ runtimeToolPolicy: {
735
+ version: 1,
736
+ agentTools: ["queryTableRows", "taskRun"],
737
+ runtimeTools: ["execShell"],
738
+ },
739
+ }),
740
+ loadDialogHistory: async () => [],
741
+ saveTurn: async () => {
742
+ throw new Error("local runtime should be skipped");
743
+ },
744
+ resolveProvider: async () => {
745
+ throw new Error("local provider should be skipped");
746
+ },
747
+ executeTool: async () => {
748
+ throw new Error("local tools should be skipped");
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 ok", dialogId: "dialog-server-policy" });
755
+ },
756
+ });
757
+
758
+ expect(result).toEqual({ exitCode: 0, dialogId: "dialog-server-policy" });
759
+ expect(httpCalls).toHaveLength(1);
760
+ expect(httpCalls[0]?.body.agentKey).toBe("agent-policy-platform-tools");
761
+ expect(output.text()).toContain("auto runtime: skipping local runtime");
762
+ expect(output.text()).toContain("queryTableRows, taskRun");
763
+ });
764
+
765
+ test("auto mode skips known platform agents when local config cannot be read", async () => {
766
+ const output = new CaptureOutput();
767
+ const httpCalls: Array<{ url: string; body: any }> = [];
768
+
769
+ const result = await runAgentTurn({
770
+ agentName: "nolo-project-manager",
771
+ agentKey: NOLO_PROJECT_MANAGER_AGENT_KEY,
772
+ serverUrl: "https://us.nolo.chat",
773
+ message: "write task rows",
774
+ scriptDir: "C:/missing/scripts",
775
+ env: { AUTH_TOKEN: "token-123", OPENAI_API_KEY: "local-provider-present" },
776
+ output,
777
+ runtimeMode: "auto",
778
+ localRuntimeAdapter: {
779
+ host: "cli",
780
+ capabilities: ["leveldb-agent-config", "local-provider", "local-tools"],
781
+ loadAgentConfig: async () => {
782
+ throw new Error("Database failed to open: LOCK");
783
+ },
784
+ loadDialogHistory: async () => {
785
+ throw new Error("local runtime should be skipped");
786
+ },
787
+ saveTurn: async () => {
788
+ throw new Error("local runtime should be skipped");
789
+ },
790
+ resolveProvider: async () => {
791
+ throw new Error("local provider should be skipped");
792
+ },
793
+ executeTool: async () => {
794
+ throw new Error("local tools should be skipped");
795
+ },
796
+ },
797
+ scriptPathExists: () => false,
798
+ fetchImpl: async (url, init) => {
799
+ httpCalls.push({ url: String(url), body: JSON.parse(String(init?.body)) });
800
+ return Response.json({ content: "server ok", dialogId: "dialog-server" });
801
+ },
802
+ });
803
+
804
+ expect(result).toEqual({ exitCode: 0, dialogId: "dialog-server" });
805
+ expect(httpCalls).toHaveLength(1);
806
+ expect(httpCalls[0]?.body.agentKey).toBe(NOLO_PROJECT_MANAGER_AGENT_KEY);
807
+ expect(output.text()).toContain("known platform agent");
808
+ expect(output.text()).toContain("nolo-project-manager -> working");
809
+ expect(output.text()).toContain("nolo-project-manager > server ok");
810
+ });
811
+
618
812
  test("builds the default local adapter when env requests local mode", async () => {
619
813
  const output = new CaptureOutput();
620
814
  const builtModes: string[] = [];
@@ -685,11 +879,67 @@ describe("cli agent run client", () => {
685
879
  });
686
880
 
687
881
  expect(result).toEqual({ exitCode: 0, dialogId: "dialog-stream" });
688
- expect(output.text()).toContain("nolo -> working");
689
- expect(output.text()).toContain("nolo > 你好");
690
- });
691
-
692
- test("prints an auth hint when installed without repo scripts or AUTH_TOKEN", async () => {
882
+ expect(output.text()).toContain("nolo -> working");
883
+ expect(output.text()).toContain("nolo > 你好");
884
+ });
885
+
886
+ test("returns a recoverable dialog when a server stream drops after dialog creation", async () => {
887
+ const output = new CaptureOutput();
888
+
889
+ const result = await runAgentTurn({
890
+ agentName: "nolo",
891
+ agentKey: "agent-pub-test",
892
+ serverUrl: "https://nolo.chat",
893
+ message: "hello",
894
+ scriptDir: "C:/missing/scripts",
895
+ env: { AUTH_TOKEN: "token-123" },
896
+ output,
897
+ scriptPathExists: () => false,
898
+ fetchImpl: async () => {
899
+ let sent = false;
900
+ return new Response(
901
+ new ReadableStream({
902
+ pull(controller) {
903
+ const encoder = new TextEncoder();
904
+ if (!sent) {
905
+ sent = true;
906
+ controller.enqueue(
907
+ encoder.encode(
908
+ [
909
+ `data: ${JSON.stringify({
910
+ type: "dialog",
911
+ dialogId: "dialog-recoverable",
912
+ status: "running",
913
+ })}`,
914
+ "",
915
+ `data: ${JSON.stringify({ type: "text", content: "partial" })}`,
916
+ "",
917
+ ].join("\n")
918
+ )
919
+ );
920
+ return;
921
+ }
922
+ controller.error(new Error("socket closed"));
923
+ },
924
+ }),
925
+ {
926
+ status: 200,
927
+ headers: { "Content-Type": "text/event-stream" },
928
+ }
929
+ );
930
+ },
931
+ });
932
+
933
+ expect(result).toEqual({
934
+ exitCode: 0,
935
+ dialogId: "dialog-recoverable",
936
+ streamInterrupted: true,
937
+ });
938
+ expect(output.text()).toContain("dialog dialog-recoverable was created");
939
+ expect(output.text()).toContain("read the dialog before retrying");
940
+ });
941
+
942
+ test("prints an auth hint when installed without repo scripts or AUTH_TOKEN", async () => {
693
943
  const output = new CaptureOutput();
694
944
 
695
945
  const result = await runAgentTurn({