minimal-agent 0.11.0 → 0.12.0

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/package.json +4 -1
  2. package/plugins/workflow-runner/plugin.js +27 -15
  3. package/plugins/workflow-runner/plugin.ts +34 -20
  4. package/plugins/workflow-runner/src/agentCommon.js +13 -12
  5. package/plugins/workflow-runner/src/agentCommon.ts +22 -13
  6. package/plugins/workflow-runner/src/index.js +5 -3
  7. package/plugins/workflow-runner/src/index.ts +31 -13
  8. package/plugins/workflow-runner/src/multimodal.js +2 -1
  9. package/plugins/workflow-runner/src/multimodal.ts +2 -1
  10. package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
  11. package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
  12. package/plugins/workflow-runner/src/stepExecutors/tool.js +68 -57
  13. package/plugins/workflow-runner/src/stepExecutors/tool.ts +88 -66
  14. package/plugins/workflow-runner/src/toolRuntime.js +48 -0
  15. package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
  16. package/plugins/workflow-runner/src/types.ts +26 -7
  17. package/plugins/workflow-runner/test/context-files.test.ts +47 -16
  18. package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
  19. package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
  20. package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
  21. package/src/cli/print.js +19 -4
  22. package/src/cli/print.ts +17 -4
  23. package/src/cli/streamJson.js +19 -4
  24. package/src/cli/streamJson.ts +17 -4
  25. package/src/config/configDefaults.js +25 -0
  26. package/src/config/configDefaults.ts +33 -0
  27. package/src/config/configFile.js +2 -21
  28. package/src/config/configFile.ts +11 -24
  29. package/src/config/configManagement.js +1 -1
  30. package/src/config/configManagement.ts +2 -2
  31. package/src/context/compact.js +4 -0
  32. package/src/context/compact.ts +4 -0
  33. package/src/context/contextBundle.js +38 -13
  34. package/src/context/contextBundle.ts +47 -13
  35. package/src/harness/stallGuard.js +19 -4
  36. package/src/harness/stallGuard.ts +30 -7
  37. package/src/loop.js +95 -11
  38. package/src/loop.ts +125 -16
  39. package/src/main.js +8 -1
  40. package/src/main.tsx +7 -1
  41. package/src/memory/constants.js +10 -0
  42. package/src/memory/constants.ts +13 -0
  43. package/src/memory/episodicMemory.js +274 -2
  44. package/src/memory/episodicMemory.ts +306 -1
  45. package/src/memory/fileStore.js +24 -4
  46. package/src/memory/fileStore.ts +22 -2
  47. package/src/memory/memoryService.js +79 -12
  48. package/src/memory/memoryService.ts +108 -9
  49. package/src/memory/repository.js +415 -85
  50. package/src/memory/repository.ts +437 -86
  51. package/src/memory/turnJournal.js +95 -0
  52. package/src/memory/turnJournal.ts +120 -0
  53. package/src/memory/types.ts +16 -0
  54. package/src/plugins/pluginApi.ts +30 -6
  55. package/src/plugins/pluginRunner.js +6 -2
  56. package/src/plugins/pluginRunner.ts +12 -7
  57. package/src/prompts/policyRegistry.js +27 -0
  58. package/src/prompts/policyRegistry.ts +27 -0
  59. package/src/prompts/promptAssembly.js +39 -7
  60. package/src/prompts/promptAssembly.ts +145 -59
  61. package/src/session/runAgentTurn.js +43 -21
  62. package/src/session/runAgentTurn.ts +68 -26
  63. package/src/session/runTurn.js +11 -46
  64. package/src/session/runTurn.ts +12 -46
  65. package/src/session/runtimeStore.js +149 -0
  66. package/src/session/runtimeStore.ts +197 -0
  67. package/src/session/sessionState.js +5 -0
  68. package/src/session/sessionState.ts +14 -0
  69. package/src/tools/agent/agent.js +143 -14
  70. package/src/tools/agent/agent.ts +149 -14
  71. package/src/tools/agent/agentTaskService.js +60 -0
  72. package/src/tools/agent/agentTaskService.ts +113 -0
  73. package/src/tools/agent/agentTypes.js +5 -0
  74. package/src/tools/agent/agentTypes.ts +5 -0
  75. package/src/tools/bash/bash.js +31 -11
  76. package/src/tools/bash/bash.ts +29 -10
  77. package/src/tools/capabilities.js +5 -0
  78. package/src/tools/capabilities.ts +11 -1
  79. package/src/tools/edit/edit.js +7 -4
  80. package/src/tools/edit/edit.ts +7 -4
  81. package/src/tools/edit/multi-edit.js +4 -2
  82. package/src/tools/edit/multi-edit.ts +4 -2
  83. package/src/tools/glob/glob.js +156 -40
  84. package/src/tools/glob/glob.ts +185 -46
  85. package/src/tools/index.js +35 -17
  86. package/src/tools/index.ts +81 -20
  87. package/src/tools/lsp/lsp.js +165 -22
  88. package/src/tools/lsp/lsp.ts +206 -36
  89. package/src/tools/powershell/powershell.js +116 -26
  90. package/src/tools/powershell/powershell.ts +155 -30
  91. package/src/tools/presentresult/presentresult.js +10 -0
  92. package/src/tools/presentresult/presentresult.ts +10 -0
  93. package/src/tools/read/read.js +244 -29
  94. package/src/tools/read/read.ts +391 -59
  95. package/src/tools/shared/diffSummary.js +44 -0
  96. package/src/tools/shared/diffSummary.ts +63 -0
  97. package/src/tools/shared/fileActivity.js +2 -1
  98. package/src/tools/shared/fileActivity.ts +3 -0
  99. package/src/tools/shared/permanentDeleteGuard.js +26 -0
  100. package/src/tools/shared/permanentDeleteGuard.ts +34 -0
  101. package/src/tools/shared/toolOutputStore.js +97 -0
  102. package/src/tools/shared/toolOutputStore.ts +110 -0
  103. package/src/tools/skill/skill.js +86 -0
  104. package/src/tools/skill/skill.ts +94 -0
  105. package/src/tools/snip/snip.js +67 -0
  106. package/src/tools/snip/snip.ts +80 -0
  107. package/src/tools/tasks/backgroundTaskService.js +79 -28
  108. package/src/tools/tasks/backgroundTaskService.ts +165 -49
  109. package/src/tools/tasks/taskManagement.js +163 -0
  110. package/src/tools/tasks/taskManagement.ts +180 -0
  111. package/src/tools/tasks/taskOutput.js +64 -14
  112. package/src/tools/tasks/taskOutput.ts +102 -23
  113. package/src/tools/tasks/taskStop.js +48 -8
  114. package/src/tools/tasks/taskStop.ts +69 -9
  115. package/src/tools/tasks/taskStore.js +46 -0
  116. package/src/tools/tasks/taskStore.ts +77 -0
  117. package/src/tools/terminal/terminalCapture.js +167 -0
  118. package/src/tools/terminal/terminalCapture.ts +179 -0
  119. package/src/tools/trash/trash.js +233 -0
  120. package/src/tools/trash/trash.ts +283 -0
  121. package/src/tools/worktree/sessionWorktree.js +128 -0
  122. package/src/tools/worktree/sessionWorktree.ts +158 -0
  123. package/src/tools/write/write.js +6 -2
  124. package/src/tools/write/write.ts +6 -2
  125. package/src/types.ts +12 -0
  126. package/src/ui/hooks/useChat.js +31 -11
  127. package/src/ui/hooks/useChat.ts +31 -13
  128. package/src/web/protocol.ts +78 -3
@@ -15,11 +15,19 @@
15
15
  * ============================================================
16
16
  */
17
17
 
18
- import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
19
-
18
+ import {
19
+ afterAll,
20
+ beforeAll,
21
+ beforeEach,
22
+ describe,
23
+ expect,
24
+ it,
25
+ mock,
26
+ } from 'bun:test';
27
+
28
+ import type { Provider, Tool } from '../../../src/types.js';
20
29
  import { execLlmStep } from '../src/stepExecutors/llm.js';
21
30
  import { VarStack } from '../src/types.js';
22
- import type { Provider } from '../../../src/types.js';
23
31
  import type { RunContext, StepDef } from '../src/types.js';
24
32
 
25
33
  const fakeProvider: Provider = {
@@ -67,8 +75,11 @@ interface ExecuteToolCall {
67
75
  }
68
76
  const capturedExecuteToolCalls: ExecuteToolCall[] = [];
69
77
  /** mock executeTool 返回值脚本(按调用顺序);用尽则回退到 { ok:true, content:'OK' } */
70
- let mockToolResults: Array<{ ok: true; content: string } | { ok: false; error: string }> = [];
78
+ let mockToolResults: Array<
79
+ { ok: true; content: string } | { ok: false; error: string }
80
+ > = [];
71
81
 
82
+ import { chat as realChatImport } from '../../../src/llm/client.js';
72
83
  // 复用真实的 ALL_TOOLS / getToolByName(llm.ts 的白名单过滤要用),
73
84
  // 只把 executeTool 替换成 mock 版本。
74
85
  //
@@ -80,7 +91,6 @@ import {
80
91
  executeTool as realExecuteTool,
81
92
  getToolByName as realGetToolByName,
82
93
  } from '../../../src/tools/index.js';
83
- import { chat as realChatImport } from '../../../src/llm/client.js';
84
94
 
85
95
  // 在 mock 安装前把真实 chat 函数引用拷到值变量,避免 ESM live binding
86
96
  // 在 mock.module 后跟着变成 mock 版本(afterAll 复原才能拿到真函数)
@@ -140,7 +150,10 @@ function installChatMock(): void {
140
150
  argumentsDelta: turn.argsJson,
141
151
  };
142
152
  }
143
- yield { type: 'done', stopReason: turn.kind === 'tool_call' ? 'tool_calls' : 'stop' };
153
+ yield {
154
+ type: 'done',
155
+ stopReason: turn.kind === 'tool_call' ? 'tool_calls' : 'stop',
156
+ };
144
157
  },
145
158
  }));
146
159
  }
@@ -156,7 +169,12 @@ describe('workflow-runner context_files (ADR-05 阶段 4)', () => {
156
169
  mockTurns = [];
157
170
  mockTurnIdx = 0;
158
171
  mockToolResults = [];
159
- ctx = { provider: fakeProvider, history: [], cwd: process.cwd(), tools: [] };
172
+ ctx = {
173
+ provider: fakeProvider,
174
+ history: [],
175
+ cwd: process.cwd(),
176
+ tools: [],
177
+ };
160
178
  });
161
179
 
162
180
  it('pure llm 不下发工具,避免单轮设计类步骤误调工具后撞 maxTurns=1', async () => {
@@ -175,6 +193,7 @@ describe('workflow-runner context_files (ADR-05 阶段 4)', () => {
175
193
  });
176
194
 
177
195
  it('case 1: 基本 Read 引用 — tool_call 一轮 + 纯文本一轮', async () => {
196
+ ctx.tools = [{ name: 'Read' } as Tool];
178
197
  mockTurns = [
179
198
  {
180
199
  kind: 'tool_call',
@@ -190,8 +209,7 @@ describe('workflow-runner context_files (ADR-05 阶段 4)', () => {
190
209
  id: 'read-and-answer',
191
210
  llm: 'please summarize',
192
211
  context_files: [{ path: '/tmp/x.txt', hint: 'source data' }],
193
- // v0.9:context_files 经 runQuery(TAOR) 执行,默认下发全量工具;用 allowed_tools
194
- // 白名单把下发工具收敛到 Read(验证 loop.ts 的 allowedTools 过滤 + llm.ts 透传)。
212
+ // context_files 经 runQuery(TAOR) 执行;步骤白名单与宿主有效工具集取交集。
195
213
  allowed_tools: ['Read'],
196
214
  };
197
215
 
@@ -212,8 +230,9 @@ describe('workflow-runner context_files (ADR-05 阶段 4)', () => {
212
230
  expect(parsedArgs.file_path).toBe('/tmp/x.txt');
213
231
 
214
232
  // 第一轮 user message 包含路径和 hint
215
- const firstUserMsg = (capturedChatArgs[0].messages as Array<{ role: string; content: string }>)
216
- .find((m) => m.role === 'user');
233
+ const firstUserMsg = (
234
+ capturedChatArgs[0].messages as Array<{ role: string; content: string }>
235
+ ).find((m) => m.role === 'user');
217
236
  expect(firstUserMsg).toBeDefined();
218
237
  expect(firstUserMsg!.content).toContain('/tmp/x.txt');
219
238
  expect(firstUserMsg!.content).toContain('source data');
@@ -225,12 +244,23 @@ describe('workflow-runner context_files (ADR-05 阶段 4)', () => {
225
244
  });
226
245
 
227
246
  it('case 2: 永不收敛(每轮都调工具)→ 被 maxTurns 严格兜住,不无限循环', async () => {
247
+ ctx.tools = [{ name: 'Read' } as Tool];
228
248
  // v0.9 重构移除了 miniReAct 的"重复工具调用早停",改由主 TAOR 引擎(runQuery)的
229
249
  // maxTurns 硬上限兜底。本用例验证安全属性:模型每轮都调工具、永不收敛时,chat 调用
230
250
  // 次数被 maxTurns 严格限制(不会无限循环),不再依赖已移除的重复检测。
231
251
  mockTurns = [
232
- { kind: 'tool_call', callId: 'c1', name: 'Read', argsJson: '{"file_path":"/a"}' },
233
- { kind: 'tool_call', callId: 'c2', name: 'Read', argsJson: '{"file_path":"/a"}' },
252
+ {
253
+ kind: 'tool_call',
254
+ callId: 'c1',
255
+ name: 'Read',
256
+ argsJson: '{"file_path":"/a"}',
257
+ },
258
+ {
259
+ kind: 'tool_call',
260
+ callId: 'c2',
261
+ name: 'Read',
262
+ argsJson: '{"file_path":"/a"}',
263
+ },
234
264
  ];
235
265
  mockToolResults = [
236
266
  { ok: true, content: 'A' },
@@ -246,13 +276,14 @@ describe('workflow-runner context_files (ADR-05 阶段 4)', () => {
246
276
 
247
277
  // 不无限循环:chat 调用次数被 maxTurns(=2) 严格兜住。
248
278
  // 撞顶后的半截产出不可信,execLlmStep 必须抛错触发 runner retry/halt。
249
- await expect(drainLlm(execLlmStep(step, new VarStack(), ctx))).rejects.toThrow(
250
- '达到最大轮数 2 被截断',
251
- );
279
+ await expect(
280
+ drainLlm(execLlmStep(step, new VarStack(), ctx)),
281
+ ).rejects.toThrow('达到最大轮数 2 被截断');
252
282
  expect(capturedChatArgs.length).toBe(2);
253
283
  });
254
284
 
255
285
  it('case 3: output_schema + tools — 走 [json+tools] 前缀', async () => {
286
+ ctx.tools = [{ name: 'Read' } as Tool];
256
287
  mockTurns = [
257
288
  {
258
289
  kind: 'tool_call',
@@ -25,6 +25,7 @@ describe('框架侧:runCommand 必须带上 host', () => {
25
25
  expect(call).toContain('host:');
26
26
  expect(call).toContain('computerRun');
27
27
  expect(call).toContain('browserRun');
28
+ expect(call).toContain('executeTool: options.executeTool');
28
29
  });
29
30
 
30
31
  test('PluginContext 用 Pick<ToolCallContext> 绑定,而不是重新声明', async () => {
@@ -40,9 +41,14 @@ describe('能力常量单一来源', () => {
40
41
  test('loader 的白名单与 types 的 HostCapability 一致', async () => {
41
42
  // 两处不一致的表现是:yaml 写了某个能力、loader 放行、运行时却识别不了,
42
43
  // 或反过来 loader 拦掉一个其实支持的能力。都属于静默错。
43
- const types = await Bun.file(new URL('../src/types.ts', import.meta.url)).text();
44
- const declared = /export type HostCapability =([^;]+);/.exec(types)?.[1] ?? '';
45
- const fromTypes = [...declared.matchAll(/'([a-z]+)'/g)].map((m) => m[1]).sort();
44
+ const types = await Bun.file(
45
+ new URL('../src/types.ts', import.meta.url),
46
+ ).text();
47
+ const declared =
48
+ /export type HostCapability =([^;]+);/.exec(types)?.[1] ?? '';
49
+ const fromTypes = [...declared.matchAll(/'([a-z]+)'/g)]
50
+ .map((m) => m[1])
51
+ .sort();
46
52
  expect(fromTypes).toEqual([...VALID_HOST_CAPABILITIES].sort());
47
53
  });
48
54
  });
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { describe, expect, it } from 'bun:test';
7
7
 
8
+ import { ALL_TOOLS } from '../../../src/tools/index.js';
8
9
  import type {
9
10
  ChatContentPart,
10
11
  LlmStreamEvent,
@@ -16,7 +17,6 @@ import { execAgentStep } from '../src/stepExecutors/agent.js';
16
17
  import { execLlmStep } from '../src/stepExecutors/llm.js';
17
18
  import { VarStack } from '../src/types.js';
18
19
  import type { RunContext, StepDef } from '../src/types.js';
19
- import { ALL_TOOLS } from '../../../src/tools/index.js';
20
20
 
21
21
  const provider: Provider = {
22
22
  name: 'test',
@@ -72,7 +72,11 @@ describe('workflow-runner multimodal images', () => {
72
72
  const chat = async function* (): AsyncGenerator<LlmStreamEvent> {
73
73
  if (round++ === 0) {
74
74
  yield { type: 'tool_call_delta', index: 0, id: 'hidden', name: 'Bash' };
75
- yield { type: 'tool_call_delta', index: 0, argumentsDelta: '{"command":"echo no"}' };
75
+ yield {
76
+ type: 'tool_call_delta',
77
+ index: 0,
78
+ argumentsDelta: '{"command":"echo no"}',
79
+ };
76
80
  yield { type: 'done', stopReason: 'tool_calls' };
77
81
  return;
78
82
  }
@@ -90,15 +94,58 @@ describe('workflow-runner multimodal images', () => {
90
94
  return { ok: true, content: 'unexpected' };
91
95
  },
92
96
  };
93
- const result = await drain(execAgentStep(
94
- { id: 'bounded', agent: '完成任务', allowed_tools: ['Bash'] },
95
- new VarStack(),
96
- ctx,
97
- ));
97
+ const result = await drain(
98
+ execAgentStep(
99
+ { id: 'bounded', agent: '完成任务', allowed_tools: ['Bash'] },
100
+ new VarStack(),
101
+ ctx,
102
+ ),
103
+ );
98
104
  expect(executed).toBe(0);
99
105
  expect(result.raw.text).toBe('已改道。');
100
106
  });
101
107
 
108
+ it('an empty host tool catalog remains empty instead of restoring global tools', async () => {
109
+ let round = 0;
110
+ let executed = 0;
111
+ const chat = async function* (): AsyncGenerator<LlmStreamEvent> {
112
+ if (round++ === 0) {
113
+ yield { type: 'tool_call_delta', index: 0, id: 'hidden', name: 'Read' };
114
+ yield {
115
+ type: 'tool_call_delta',
116
+ index: 0,
117
+ argumentsDelta: '{"file_path":"README.md"}',
118
+ };
119
+ yield { type: 'done', stopReason: 'tool_calls' };
120
+ return;
121
+ }
122
+ yield { type: 'text_delta', delta: '没有可用工具。' };
123
+ yield { type: 'done', stopReason: 'stop' };
124
+ };
125
+ const ctx: RunContext = {
126
+ provider,
127
+ history: [{ role: 'system', content: 'stable' }],
128
+ cwd: process.cwd(),
129
+ tools: [],
130
+ chat,
131
+ executeTool: async () => {
132
+ executed += 1;
133
+ return { ok: true, content: 'unexpected' };
134
+ },
135
+ };
136
+
137
+ const result = await drain(
138
+ execAgentStep(
139
+ { id: 'empty-bound', agent: '完成任务', allowed_tools: ['Read'] },
140
+ new VarStack(),
141
+ ctx,
142
+ ),
143
+ );
144
+
145
+ expect(executed).toBe(0);
146
+ expect(result.raw.text).toBe('没有可用工具。');
147
+ });
148
+
102
149
  it('execLlmStep 把 images 传成 image_url content block,并支持变量插值', async () => {
103
150
  const captured: Message[][] = [];
104
151
  const ctx = createContext(captured);
@@ -195,7 +242,9 @@ describe('workflow-runner multimodal images', () => {
195
242
 
196
243
  expect(result.raw.text).toBe('图片可用');
197
244
  expect(messagesContainImage(captured[0])).toBe(true);
198
- expect(ctx.history).toEqual([{ role: 'system', content: 'workflow system prompt' }]);
245
+ expect(ctx.history).toEqual([
246
+ { role: 'system', content: 'workflow system prompt' },
247
+ ]);
199
248
  });
200
249
 
201
250
  it('isolated 多模态步骤之后的普通 agent 不会收到前一步 image_url', async () => {
@@ -262,7 +311,7 @@ describe('workflow-runner multimodal images', () => {
262
311
  provider,
263
312
  history: [{ role: 'system', content: 'workflow system prompt' }],
264
313
  cwd: process.cwd(),
265
- tools: [],
314
+ tools: ALL_TOOLS.filter((tool) => tool.name === 'Write'),
266
315
  chat,
267
316
  executeTool: async () => {
268
317
  toolCalled = true;
@@ -285,7 +334,9 @@ describe('workflow-runner multimodal images', () => {
285
334
 
286
335
  expect(toolCalled).toBe(true);
287
336
  expect(result.raw.text).toBe('文件已写好。');
288
- expect(ctx.history).toEqual([{ role: 'system', content: 'workflow system prompt' }]);
337
+ expect(ctx.history).toEqual([
338
+ { role: 'system', content: 'workflow system prompt' },
339
+ ]);
289
340
  expect(captured.length).toBe(2);
290
341
  });
291
342
  });
@@ -0,0 +1,168 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ ALL_TOOLS,
5
+ type LoopEvent,
6
+ type Provider,
7
+ type ToolCallContext,
8
+ type ToolResult,
9
+ createAgentRuntimeContext,
10
+ createSessionState,
11
+ } from '../../../src/plugin-sdk.js';
12
+ import { execToolStep } from '../src/stepExecutors/tool.js';
13
+ import { type RunContext, type StepResult, VarStack } from '../src/types.js';
14
+
15
+ const provider: Provider = {
16
+ name: 'workflow-tool-test',
17
+ baseURL: '',
18
+ apiKey: '',
19
+ model: 'fixture',
20
+ contextWindow: 128_000,
21
+ };
22
+
23
+ async function collect(
24
+ generator: AsyncGenerator<LoopEvent, StepResult, void>,
25
+ ): Promise<{ events: LoopEvent[]; result: StepResult }> {
26
+ const events: LoopEvent[] = [];
27
+ while (true) {
28
+ const next = await generator.next();
29
+ if (next.done) return { events, result: next.value };
30
+ events.push(next.value);
31
+ }
32
+ }
33
+
34
+ function makeContext(
35
+ executeTool: NonNullable<RunContext['executeTool']>,
36
+ ): RunContext {
37
+ const sessionState = createSessionState();
38
+ sessionState.activeWorktree = {
39
+ id: 'wt_test',
40
+ path: '/tmp/workflow-active-worktree',
41
+ branch: 'workflow/test',
42
+ baseSha: 'deadbeef',
43
+ gitRoot: '/tmp/workflow-root',
44
+ };
45
+ return {
46
+ provider,
47
+ history: [],
48
+ cwd: '/tmp/workflow-root',
49
+ tools: ALL_TOOLS,
50
+ sessionState,
51
+ runtimeContext: createAgentRuntimeContext({
52
+ surface: 'workflow',
53
+ profile: 'main',
54
+ cwd: '/tmp/workflow-root',
55
+ workspaceId: 'workspace_test',
56
+ sessionId: 'session_test',
57
+ owner: 'local-owner',
58
+ memoryAccess: 'read-only',
59
+ interactiveAsk: false,
60
+ }),
61
+ promptCache: {
62
+ scopeKey: 'workflow-test-scope',
63
+ conversationKey: 'workflow-test-conversation',
64
+ },
65
+ executeTool,
66
+ };
67
+ }
68
+
69
+ describe('workflow deterministic tool runtime', () => {
70
+ test('all registered tools use the same generic deterministic node dispatcher', async () => {
71
+ const dispatched: string[] = [];
72
+ const ctx = makeContext(async (name) => {
73
+ dispatched.push(name);
74
+ return { ok: true, content: `${name} ok` };
75
+ });
76
+
77
+ for (const tool of ALL_TOOLS) {
78
+ const { result } = await collect(
79
+ execToolStep(
80
+ { id: `catalog_${tool.name}`, tool: tool.name, args: {} },
81
+ new VarStack(),
82
+ ctx,
83
+ ),
84
+ );
85
+ expect(result.raw).toMatchObject({ content: `${tool.name} ok` });
86
+ }
87
+
88
+ expect(dispatched).toEqual(ALL_TOOLS.map((tool) => tool.name));
89
+ expect(new Set(dispatched).size).toBe(ALL_TOOLS.length);
90
+ });
91
+
92
+ test('passes canonical runtime context and follows the active worktree cwd', async () => {
93
+ let captured: ToolCallContext | undefined;
94
+ const ctx = makeContext(async (_name, _args, _signal, context) => {
95
+ captured = context;
96
+ return { ok: true, content: 'ok' };
97
+ });
98
+
99
+ await collect(
100
+ execToolStep(
101
+ { id: 'context', tool: 'Read', args: { file_path: 'README.md' } },
102
+ new VarStack(),
103
+ ctx,
104
+ ),
105
+ );
106
+
107
+ expect(captured).toBeDefined();
108
+ expect(captured?.provider).toBe(provider);
109
+ expect(captured?.history).toBe(ctx.history);
110
+ expect(captured?.sessionState).toBe(ctx.sessionState);
111
+ expect(captured?.workspaceId).toBe('workspace_test');
112
+ expect(captured?.sessionId).toBe('session_test');
113
+ expect(captured?.memoryAccess).toBe('read-only');
114
+ expect(captured?.cwd).toBe('/tmp/workflow-active-worktree');
115
+ expect(captured?.toolCallId).toBe('wf_context');
116
+ expect(captured?.promptCache).toBe(ctx.promptCache);
117
+ });
118
+
119
+ test('streams tool progress and preserves structured result fields', async () => {
120
+ const metadata = {
121
+ kind: 'process_execution' as const,
122
+ shell: 'bash' as const,
123
+ mode: 'foreground' as const,
124
+ commandKind: 'test' as const,
125
+ status: 'completed' as const,
126
+ exitCode: 0,
127
+ };
128
+ const ctx = makeContext(async (_name, _args, _signal, context) => {
129
+ context?.reportProgress?.('running tests');
130
+ await Promise.resolve();
131
+ return {
132
+ ok: true,
133
+ content: 'tests passed',
134
+ metadata,
135
+ recovery: 'none',
136
+ sensitivity: 'local',
137
+ visionNote: 'verification image',
138
+ } satisfies ToolResult;
139
+ });
140
+
141
+ const { events, result } = await collect(
142
+ execToolStep(
143
+ { id: 'progress', tool: 'Bash', args: { command: 'bun test' } },
144
+ new VarStack(),
145
+ ctx,
146
+ ),
147
+ );
148
+
149
+ expect(events.map((event) => event.type)).toEqual([
150
+ 'tool_start',
151
+ 'tool_progress',
152
+ 'tool_end',
153
+ ]);
154
+ expect(events[1]).toMatchObject({
155
+ type: 'tool_progress',
156
+ toolName: 'Bash',
157
+ toolCallId: 'wf_progress',
158
+ message: 'running tests',
159
+ });
160
+ expect(events[2]).toMatchObject({ type: 'tool_end', metadata });
161
+ expect(result.raw).toMatchObject({
162
+ metadata,
163
+ recovery: 'none',
164
+ sensitivity: 'local',
165
+ visionNote: 'verification image',
166
+ });
167
+ });
168
+ });
package/src/cli/print.js CHANGED
@@ -27,7 +27,9 @@ import { updateSessionMetadata } from '../context/sessionRegistry.js';
27
27
  import { workspaceIndexService } from '../context/workspaceIndex.js';
28
28
  import { normalizeImageDetail } from '../multimodal.js';
29
29
  import { runWithPlugins } from '../plugins/pluginRunner.js';
30
- import { buildTransientPromptBundle } from '../prompts/system.js';
30
+ import { buildBaseSystemPrompt, buildTransientPromptBundle } from '../prompts/system.js';
31
+ import { getOrCreateSessionState, sessionStateKey } from '../session/sessionState.js';
32
+ import { hydrateSessionWorktree } from '../tools/worktree/sessionWorktree.js';
31
33
  import { ALL_TOOLS } from '../tools/index.js';
32
34
  import { createPromptCacheContext } from '../llm/promptCache.js';
33
35
  import { createAgentRuntimeContext, filterToolsForRuntime, memoryContextFromRuntime, } from '../runtime/agentRuntimeContext.js';
@@ -232,10 +234,16 @@ export async function runPrintMode(provider, args, initialHistory, options) {
232
234
  void finalizeOnce().finally(() => process.exit(130));
233
235
  });
234
236
  try {
235
- const cwd = options.workspace?.cwd ?? process.cwd();
236
- const workspace = await workspaceIndexService.findByPath(cwd).catch(() => null);
237
+ const workspaceRoot = options.workspace?.cwd ?? process.cwd();
238
+ const workspace = await workspaceIndexService.findByPath(workspaceRoot).catch(() => null);
237
239
  const sessionId = options.workspace?.sessionId ?? 'default';
238
- const conversationKey = `workspace:${workspace?.id ?? cwd}:session:${sessionId}`;
240
+ const sessionState = getOrCreateSessionState(sessionStateKey({
241
+ surface: 'cli', workspaceId: workspace?.id,
242
+ cwd: workspace?.id ? undefined : workspaceRoot, sessionId,
243
+ }));
244
+ await hydrateSessionWorktree({ sessionState, workspaceId: workspace?.id, sessionId, cwd: workspaceRoot });
245
+ const cwd = sessionState.activeWorktree?.path ?? workspaceRoot;
246
+ const conversationKey = `workspace:${workspace?.id ?? workspaceRoot}:session:${sessionId}`;
239
247
  const computerRun = options.computerUseMode && options.computerUseMode !== 'disabled'
240
248
  ? createCliComputerRun({
241
249
  mode: options.computerUseMode,
@@ -259,6 +267,11 @@ export async function runPrintMode(provider, args, initialHistory, options) {
259
267
  computer: computerRun,
260
268
  });
261
269
  const turnTools = filterToolsForRuntime(ALL_TOOLS, runtimeContext);
270
+ const stablePrompt = await buildBaseSystemPrompt(cwd, turnTools);
271
+ if (history[0]?.role === 'system')
272
+ history[0] = { ...history[0], content: stablePrompt };
273
+ else
274
+ history.unshift({ role: 'system', content: stablePrompt });
262
275
  const transientBundle = await buildTransientPromptBundle({
263
276
  cwd,
264
277
  tools: turnTools,
@@ -266,6 +279,7 @@ export async function runPrintMode(provider, args, initialHistory, options) {
266
279
  surface: 'cli',
267
280
  cwd,
268
281
  workspaceId: workspace?.id,
282
+ sessionId,
269
283
  interactiveAsk: false,
270
284
  memoryAccess: runtimeContext.memoryAccess,
271
285
  currentUserInput: prompt,
@@ -282,6 +296,7 @@ export async function runPrintMode(provider, args, initialHistory, options) {
282
296
  imageMaxLongSidePixel: imageOptions.imageMaxLongSidePixel,
283
297
  allowedTools: turnTools.map((tool) => tool.name),
284
298
  runtimeContext,
299
+ sessionState,
285
300
  ...(transientBundle ? { transientBundle } : {}),
286
301
  promptCache: createPromptCacheContext(`workspace:${workspace?.id ?? cwd}:session:${sessionId}`),
287
302
  memoryContext: memoryContextFromRuntime(runtimeContext),
package/src/cli/print.ts CHANGED
@@ -29,7 +29,9 @@ import { workspaceIndexService } from '../context/workspaceIndex.js';
29
29
  import { normalizeImageDetail } from '../multimodal.js';
30
30
  import type { DeliverableManifest, FileActivityMetadata, HarnessDiagnostics, ImageInputSpec, LoopEvent, Message, Provider } from '../types.js';
31
31
  import { runWithPlugins } from '../plugins/pluginRunner.js';
32
- import { buildTransientPromptBundle } from '../prompts/system.js';
32
+ import { buildBaseSystemPrompt, buildTransientPromptBundle } from '../prompts/system.js';
33
+ import { getOrCreateSessionState, sessionStateKey } from '../session/sessionState.js';
34
+ import { hydrateSessionWorktree } from '../tools/worktree/sessionWorktree.js';
33
35
  import { ALL_TOOLS } from '../tools/index.js';
34
36
  import { createPromptCacheContext } from '../llm/promptCache.js';
35
37
  import {
@@ -325,10 +327,16 @@ export async function runPrintMode(
325
327
  });
326
328
 
327
329
  try {
328
- const cwd = options.workspace?.cwd ?? process.cwd();
329
- const workspace = await workspaceIndexService.findByPath(cwd).catch(() => null);
330
+ const workspaceRoot = options.workspace?.cwd ?? process.cwd();
331
+ const workspace = await workspaceIndexService.findByPath(workspaceRoot).catch(() => null);
330
332
  const sessionId = options.workspace?.sessionId ?? 'default';
331
- const conversationKey = `workspace:${workspace?.id ?? cwd}:session:${sessionId}`;
333
+ const sessionState = getOrCreateSessionState(sessionStateKey({
334
+ surface: 'cli', workspaceId: workspace?.id,
335
+ cwd: workspace?.id ? undefined : workspaceRoot, sessionId,
336
+ }));
337
+ await hydrateSessionWorktree({ sessionState, workspaceId: workspace?.id, sessionId, cwd: workspaceRoot });
338
+ const cwd = sessionState.activeWorktree?.path ?? workspaceRoot;
339
+ const conversationKey = `workspace:${workspace?.id ?? workspaceRoot}:session:${sessionId}`;
332
340
  const computerRun = options.computerUseMode && options.computerUseMode !== 'disabled'
333
341
  ? createCliComputerRun({
334
342
  mode: options.computerUseMode,
@@ -352,6 +360,9 @@ export async function runPrintMode(
352
360
  computer: computerRun,
353
361
  });
354
362
  const turnTools = filterToolsForRuntime(ALL_TOOLS, runtimeContext);
363
+ const stablePrompt = await buildBaseSystemPrompt(cwd, turnTools);
364
+ if (history[0]?.role === 'system') history[0] = { ...history[0], content: stablePrompt };
365
+ else history.unshift({ role: 'system', content: stablePrompt });
355
366
  const transientBundle = await buildTransientPromptBundle({
356
367
  cwd,
357
368
  tools: turnTools,
@@ -359,6 +370,7 @@ export async function runPrintMode(
359
370
  surface: 'cli',
360
371
  cwd,
361
372
  workspaceId: workspace?.id,
373
+ sessionId,
362
374
  interactiveAsk: false,
363
375
  memoryAccess: runtimeContext.memoryAccess,
364
376
  currentUserInput: prompt,
@@ -375,6 +387,7 @@ export async function runPrintMode(
375
387
  imageMaxLongSidePixel: imageOptions.imageMaxLongSidePixel,
376
388
  allowedTools: turnTools.map((tool) => tool.name),
377
389
  runtimeContext,
390
+ sessionState,
378
391
  ...(transientBundle ? { transientBundle } : {}),
379
392
  promptCache: createPromptCacheContext(
380
393
  `workspace:${workspace?.id ?? cwd}:session:${sessionId}`,
@@ -28,7 +28,9 @@ import { saveContext } from '../context/persistContext.js';
28
28
  import { setAskHandler } from '../askBridge.js';
29
29
  import { normalizeImageDetail } from '../multimodal.js';
30
30
  import { runWithPlugins } from '../plugins/pluginRunner.js';
31
- import { buildTransientPromptBundle } from '../prompts/system.js';
31
+ import { buildBaseSystemPrompt, buildTransientPromptBundle } from '../prompts/system.js';
32
+ import { getOrCreateSessionState, sessionStateKey } from '../session/sessionState.js';
33
+ import { hydrateSessionWorktree } from '../tools/worktree/sessionWorktree.js';
32
34
  import { ALL_TOOLS } from '../tools/index.js';
33
35
  import { createPromptCacheContext } from '../llm/promptCache.js';
34
36
  import { createAgentRuntimeContext, filterToolsForRuntime, memoryContextFromRuntime, } from '../runtime/agentRuntimeContext.js';
@@ -267,10 +269,16 @@ export async function runStreamJsonMode(provider, args, initialHistory, options)
267
269
  void finalize().finally(() => process.exit(130));
268
270
  });
269
271
  try {
270
- const cwd = options.workspace?.cwd ?? getWorkingDir();
271
- const workspace = await workspaceIndexService.findByPath(cwd).catch(() => null);
272
+ const workspaceRoot = options.workspace?.cwd ?? getWorkingDir();
273
+ const workspace = await workspaceIndexService.findByPath(workspaceRoot).catch(() => null);
272
274
  const sessionId = options.workspace?.sessionId ?? 'default';
273
- const conversationKey = `workspace:${workspace?.id ?? cwd}:session:${sessionId}`;
275
+ const sessionState = getOrCreateSessionState(sessionStateKey({
276
+ surface: 'headless', workspaceId: workspace?.id,
277
+ cwd: workspace?.id ? undefined : workspaceRoot, sessionId,
278
+ }));
279
+ await hydrateSessionWorktree({ sessionState, workspaceId: workspace?.id, sessionId, cwd: workspaceRoot });
280
+ const cwd = sessionState.activeWorktree?.path ?? workspaceRoot;
281
+ const conversationKey = `workspace:${workspace?.id ?? workspaceRoot}:session:${sessionId}`;
274
282
  const computerRun = options.computerUseMode && options.computerUseMode !== 'disabled'
275
283
  ? createCliComputerRun({
276
284
  mode: options.computerUseMode,
@@ -294,6 +302,11 @@ export async function runStreamJsonMode(provider, args, initialHistory, options)
294
302
  computer: computerRun,
295
303
  });
296
304
  const turnTools = filterToolsForRuntime(ALL_TOOLS, runtimeContext);
305
+ const stablePrompt = await buildBaseSystemPrompt(cwd, turnTools);
306
+ if (history[0]?.role === 'system')
307
+ history[0] = { ...history[0], content: stablePrompt };
308
+ else
309
+ history.unshift({ role: 'system', content: stablePrompt });
297
310
  const transientBundle = await buildTransientPromptBundle({
298
311
  cwd,
299
312
  tools: turnTools,
@@ -301,6 +314,7 @@ export async function runStreamJsonMode(provider, args, initialHistory, options)
301
314
  surface: 'headless',
302
315
  cwd,
303
316
  workspaceId: workspace?.id,
317
+ sessionId,
304
318
  interactiveAsk: control,
305
319
  memoryAccess: runtimeContext.memoryAccess,
306
320
  currentUserInput: prompt,
@@ -316,6 +330,7 @@ export async function runStreamJsonMode(provider, args, initialHistory, options)
316
330
  imageDetail: cliImageOptions.imageDetail,
317
331
  imageMaxLongSidePixel: cliImageOptions.imageMaxLongSidePixel,
318
332
  runtimeContext,
333
+ sessionState,
319
334
  allowedTools: turnTools.map((tool) => tool.name),
320
335
  ...(transientBundle ? { transientBundle } : {}),
321
336
  promptCache: createPromptCacheContext(`workspace:${workspace?.id ?? cwd}:session:${sessionId}`),