@downcity/agent 1.1.276 → 1.1.278

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 (56) hide show
  1. package/bin/agent/Agent.d.ts.map +1 -1
  2. package/bin/agent/Agent.js +0 -1
  3. package/bin/agent/Agent.js.map +1 -1
  4. package/bin/agent/AgentSessions.d.ts +0 -4
  5. package/bin/agent/AgentSessions.d.ts.map +1 -1
  6. package/bin/agent/AgentSessions.js +2 -17
  7. package/bin/agent/AgentSessions.js.map +1 -1
  8. package/bin/session/Session.d.ts +11 -1
  9. package/bin/session/Session.d.ts.map +1 -1
  10. package/bin/session/Session.js +61 -24
  11. package/bin/session/Session.js.map +1 -1
  12. package/bin/session/messages/SessionAssistantMessageWriter.d.ts +6 -1
  13. package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
  14. package/bin/session/messages/SessionAssistantMessageWriter.js +52 -15
  15. package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
  16. package/bin/session/messages/SessionToolPartGate.d.ts +23 -0
  17. package/bin/session/messages/SessionToolPartGate.d.ts.map +1 -0
  18. package/bin/session/messages/SessionToolPartGate.js +64 -0
  19. package/bin/session/messages/SessionToolPartGate.js.map +1 -0
  20. package/bin/session/storage/Instruction.d.ts +14 -0
  21. package/bin/session/storage/Instruction.d.ts.map +1 -0
  22. package/bin/session/storage/Instruction.js +44 -0
  23. package/bin/session/storage/Instruction.js.map +1 -0
  24. package/bin/session/storage/Paths.d.ts +8 -0
  25. package/bin/session/storage/Paths.d.ts.map +1 -1
  26. package/bin/session/storage/Paths.js +10 -0
  27. package/bin/session/storage/Paths.js.map +1 -1
  28. package/bin/types/agent/SessionActor.d.ts +2 -0
  29. package/bin/types/agent/SessionActor.d.ts.map +1 -1
  30. package/bin/types/session/SessionInstruction.d.ts +22 -0
  31. package/bin/types/session/SessionInstruction.d.ts.map +1 -0
  32. package/bin/types/session/SessionInstruction.js +9 -0
  33. package/bin/types/session/SessionInstruction.js.map +1 -0
  34. package/bin/types/session/SessionOptions.d.ts +6 -2
  35. package/bin/types/session/SessionOptions.d.ts.map +1 -1
  36. package/bin/types/session/SessionQueue.d.ts +2 -18
  37. package/bin/types/session/SessionQueue.d.ts.map +1 -1
  38. package/bin/types/session/SessionTool.d.ts +9 -0
  39. package/bin/types/session/SessionTool.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/scripts/session-config-turn-boundary.test.mjs +156 -5
  42. package/scripts/session-messages.test.mjs +180 -4
  43. package/scripts/session-tool-part-gate.test.mjs +58 -0
  44. package/src/agent/Agent.ts +0 -1
  45. package/src/agent/AgentSessions.ts +2 -21
  46. package/src/session/Session.ts +68 -26
  47. package/src/session/messages/SessionAssistantMessageWriter.ts +65 -14
  48. package/src/session/messages/SessionToolPartGate.ts +67 -0
  49. package/src/session/storage/Instruction.ts +59 -0
  50. package/src/session/storage/Paths.ts +18 -0
  51. package/src/types/agent/SessionActor.ts +3 -0
  52. package/src/types/session/SessionInstruction.ts +24 -0
  53. package/src/types/session/SessionOptions.ts +6 -2
  54. package/src/types/session/SessionQueue.ts +9 -29
  55. package/src/types/session/SessionTool.ts +10 -0
  56. package/tsconfig.tsbuildinfo +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"SessionQueue.d.ts","sourceRoot":"","sources":["../../../src/types/session/SessionQueue.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"SessionQueue.d.ts","sourceRoot":"","sources":["../../../src/types/session/SessionQueue.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,+BAA+B,CAAC;AAChF,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,iCAAiC,CAAC;AACnF,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,mCAAmC,CAAC;AACjF,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,iCAAiC,CAAC;AAE9E,mCAAmC;AACnC,MAAM,WAAW,wBAAwB;IACvC,4BAA4B;IAC5B,IAAI,EAAE,eAAe,CAAC;IACtB,mBAAmB;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,4CAA4C;IAC5C,MAAM,EAAE,0BAA0B,CAAC;IACnC,8BAA8B;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8BAA8B;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,yCAAyC;AACzC,MAAM,WAAW,sBAAsB;IACrC,4BAA4B;IAC5B,IAAI,EAAE,WAAW,CAAC;IAClB,mBAAmB;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,2BAA2B;IAC3B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7B;AAED,8CAA8C;AAC9C,MAAM,WAAW,0BAA0B;IACzC,+BAA+B;IAC/B,IAAI,EAAE,eAAe,CAAC;IACtB,mBAAmB;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,2BAA2B;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,+BAA+B;IAC/B,OAAO,EAAE,2BAA2B,CAAC;CACtC;AAED,mBAAmB;AACnB,MAAM,WAAW,0BAA0B;IACzC,uBAAuB;IACvB,IAAI,EAAE,SAAS,CAAC;IAChB,mBAAmB;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,+BAA+B;AAC/B,MAAM,MAAM,mBAAmB,GAC3B,wBAAwB,GACxB,sBAAsB,GACtB,0BAA0B,GAC1B,0BAA0B,CAAC;AAE/B,gDAAgD;AAChD,MAAM,MAAM,mBAAmB,GAAG;IAC9B,mBAAmB;IACnB,IAAI,EAAE,KAAK,CAAC;IACZ,gBAAgB;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,uCAAuC;IACvC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC7B,GACC;IACE,uBAAuB;IACvB,IAAI,EAAE,SAAS,CAAC;IAChB,gBAAgB;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,2BAA2B;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,OAAO,EAAE,2BAA2B,CAAC;CACtC,CAAC;AAEN,wBAAwB;AACxB,MAAM,WAAW,oBAAoB,CAAC,CAAC;IACrC,sBAAsB;IACtB,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IACpB,oBAAoB;IACpB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;CAC7B;AAED,mCAAmC;AACnC,MAAM,WAAW,mBAAmB;IAClC,uBAAuB;IACvB,IAAI,EAAE,QAAQ,CAAC;IACf,wBAAwB;IACxB,KAAK,EAAE,uBAAuB,CAAC;IAC/B,0CAA0C;IAC1C,eAAe,EAAE,oBAAoB,CAAC,sBAAsB,CAAC,CAAC;CAC/D;AAED,6BAA6B;AAC7B,MAAM,MAAM,gBAAgB,GAAG,mBAAmB,GAAG,mBAAmB,CAAC"}
@@ -20,4 +20,13 @@ export interface SessionToolInputReady {
20
20
  /** 已完成解析的 Tool 输入。 */
21
21
  input: unknown;
22
22
  }
23
+ /** 等待单个 canonical Tool Part 到达的异步句柄。 */
24
+ export interface SessionToolPartWaiter {
25
+ /** Tool Part 成功持久化后完成,异常结束时拒绝。 */
26
+ promise: Promise<void>;
27
+ /** Tool Part 已按 canonical 顺序持久化时释放等待。 */
28
+ resolve: () => void;
29
+ /** 当前 step 或 Gate 异常结束时拒绝等待。 */
30
+ reject: (error: Error) => void;
31
+ }
23
32
  //# sourceMappingURL=SessionTool.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"SessionTool.d.ts","sourceRoot":"","sources":["../../../src/types/session/SessionTool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,mCAAmC,CAAC;AAElF,wCAAwC;AACxC,MAAM,WAAW,4BAA4B;IAC3C,sCAAsC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,yBAAyB;IACzB,IAAI,EAAE,wBAAwB,CAAC;CAChC;AAED,oCAAoC;AACpC,MAAM,WAAW,qBAAqB;IACpC,uBAAuB;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,oBAAoB;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,sBAAsB;IACtB,KAAK,EAAE,OAAO,CAAC;CAChB"}
1
+ {"version":3,"file":"SessionTool.d.ts","sourceRoot":"","sources":["../../../src/types/session/SessionTool.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,mCAAmC,CAAC;AAElF,wCAAwC;AACxC,MAAM,WAAW,4BAA4B;IAC3C,sCAAsC;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,yBAAyB;IACzB,IAAI,EAAE,wBAAwB,CAAC;CAChC;AAED,oCAAoC;AACpC,MAAM,WAAW,qBAAqB;IACpC,uBAAuB;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,oBAAoB;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,sBAAsB;IACtB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,wCAAwC;AACxC,MAAM,WAAW,qBAAqB;IACpC,kCAAkC;IAClC,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,yCAAyC;IACzC,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,gCAAgC;IAChC,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAChC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@downcity/agent",
3
- "version": "1.1.276",
3
+ "version": "1.1.278",
4
4
  "type": "module",
5
5
  "description": "Downcity Agent 运行时 — 单 Agent 执行壳与本机 RPC 能力",
6
6
  "main": "./bin/index.js",
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 关键点(中文)
5
5
  * - 第一次 provider 请求保持阻塞,用来制造真实的运行中配置修改窗口。
6
- * - instructionenv 与 plugin registry 修改不能改变已经开始的请求。
6
+ * - Agent instruction 修改不能改变已有 Session;env 与 plugin registry 修改在 step 检查点生效。
7
7
  * - config 与 steer 在同一个 Session step 检查点提交,并继续使用同一个 turn id。
8
8
  */
9
9
 
@@ -58,7 +58,7 @@ function create_stream_text_result(text) {
58
58
  };
59
59
  }
60
60
 
61
- test("running Agent config changes and steer apply at the same Session step checkpoint", async () => {
61
+ test("Agent instruction changes only affect newly created Sessions", async () => {
62
62
  const agent_path = await fs.mkdtemp(
63
63
  path.join(os.tmpdir(), "downcity-agent-config-turn-boundary-"),
64
64
  );
@@ -133,8 +133,8 @@ test("running Agent config changes and steer apply at the same Session step chec
133
133
  assert.equal((await steer_turn.finished).success, true);
134
134
  assert.equal(plugin_stop_count, 1);
135
135
  assert.equal(provider_prompts.length, 2);
136
- assert.match(provider_prompts[1], /instruction:new/);
137
- assert.doesNotMatch(provider_prompts[1], /instruction:old/);
136
+ assert.match(provider_prompts[1], /instruction:old/);
137
+ assert.doesNotMatch(provider_prompts[1], /instruction:new/);
138
138
  assert.doesNotMatch(provider_prompts[1], /plugin-env:old/);
139
139
 
140
140
  const messages = await session.messages();
@@ -142,16 +142,167 @@ test("running Agent config changes and steer apply at the same Session step chec
142
142
  .filter((message) => message.type === "action" && message.status === "completed")
143
143
  .map((message) => message.title);
144
144
  assert.deepEqual(completed_actions, [
145
- "Agent instruction updated",
146
145
  "Agent environment updated",
147
146
  "Agent plugin runtime-config unregistered",
148
147
  ]);
148
+
149
+ // 未显式 snapshot 的 Session 重新装载时使用 Agent 当前 instruction。
150
+ await agent.sessions.clear_messages(session.id);
151
+ const restored_session = await agent.sessions.get(session.id);
152
+ const restored_system = await restored_session.system();
153
+ const restored_system_text = restored_system.blocks
154
+ .map((block) => block.content)
155
+ .join("\n");
156
+ assert.match(restored_system_text, /instruction:new/);
157
+ assert.doesNotMatch(restored_system_text, /instruction:old/);
158
+
159
+ const new_session = await agent.sessions.create({
160
+ sessionId: "config_turn_boundary_new_session",
161
+ });
162
+ const new_system = await new_session.system();
163
+ const new_system_text = new_system.blocks
164
+ .map((block) => block.content)
165
+ .join("\n");
166
+ assert.match(new_system_text, /instruction:new/);
167
+ assert.doesNotMatch(new_system_text, /instruction:old/);
149
168
  } finally {
150
169
  release_first_provider_request.resolve();
151
170
  await agent.dispose();
152
171
  }
153
172
  });
154
173
 
174
+ test("Session snapshot explicitly persists instruction.md", async () => {
175
+ const agent_path = await fs.mkdtemp(
176
+ path.join(os.tmpdir(), "downcity-agent-instruction-restart-"),
177
+ );
178
+ const model = new MockLanguageModelV3({ modelId: "instruction-restart-model" });
179
+ const first_agent = new Agent({
180
+ id: "instruction_restart_agent",
181
+ path: agent_path,
182
+ model,
183
+ instruction: ["instruction:old"],
184
+ });
185
+
186
+ try {
187
+ const session = await first_agent.sessions.create({
188
+ sessionId: "instruction_restart_session",
189
+ });
190
+ const first_system = await session.system();
191
+ assert.match(
192
+ first_system.blocks.map((block) => block.content).join("\n"),
193
+ /instruction:old/,
194
+ );
195
+ await session.snapshot();
196
+
197
+ const instruction_path = path.join(
198
+ agent_path,
199
+ ".downcity",
200
+ "agents",
201
+ "instruction_restart_agent",
202
+ "sessions",
203
+ "instruction_restart_session",
204
+ "instruction.md",
205
+ );
206
+ assert.equal(await fs.readFile(instruction_path, "utf8"), "instruction:old");
207
+
208
+ await fs.writeFile(instruction_path, "instruction:manual", "utf8");
209
+ await session.snapshot();
210
+ assert.equal(await fs.readFile(instruction_path, "utf8"), "instruction:old");
211
+ } finally {
212
+ await first_agent.dispose();
213
+ }
214
+
215
+ const restarted_agent = new Agent({
216
+ id: "instruction_restart_agent",
217
+ path: agent_path,
218
+ model,
219
+ instruction: ["instruction:new"],
220
+ });
221
+ try {
222
+ const restored_session = await restarted_agent.sessions.get(
223
+ "instruction_restart_session",
224
+ );
225
+ const restored_system = await restored_session.system();
226
+ const restored_system_text = restored_system.blocks
227
+ .map((block) => block.content)
228
+ .join("\n");
229
+ assert.match(restored_system_text, /instruction:old/);
230
+ assert.doesNotMatch(restored_system_text, /instruction:new/);
231
+ } finally {
232
+ await restarted_agent.dispose();
233
+ }
234
+
235
+ const instruction_path = path.join(
236
+ agent_path,
237
+ ".downcity",
238
+ "agents",
239
+ "instruction_restart_agent",
240
+ "sessions",
241
+ "instruction_restart_session",
242
+ "instruction.md",
243
+ );
244
+ await fs.rm(instruction_path);
245
+
246
+ const fallback_agent = new Agent({
247
+ id: "instruction_restart_agent",
248
+ path: agent_path,
249
+ model,
250
+ instruction: ["instruction:new"],
251
+ });
252
+ try {
253
+ const fallback_session = await fallback_agent.sessions.get(
254
+ "instruction_restart_session",
255
+ );
256
+ const fallback_system = await fallback_session.system();
257
+ const fallback_system_text = fallback_system.blocks
258
+ .map((block) => block.content)
259
+ .join("\n");
260
+ assert.match(fallback_system_text, /instruction:new/);
261
+ assert.doesNotMatch(fallback_system_text, /instruction:old/);
262
+ } finally {
263
+ await fallback_agent.dispose();
264
+ await fs.rm(agent_path, { recursive: true, force: true });
265
+ }
266
+ });
267
+
268
+ test("empty Session snapshot suppresses Agent instruction after restart", async () => {
269
+ const agent_path = await fs.mkdtemp(
270
+ path.join(os.tmpdir(), "downcity-agent-empty-instruction-snapshot-"),
271
+ );
272
+ const model = new MockLanguageModelV3({ modelId: "empty-snapshot-model" });
273
+ const first_agent = new Agent({
274
+ id: "empty_snapshot_agent",
275
+ path: agent_path,
276
+ model,
277
+ });
278
+ try {
279
+ const session = await first_agent.sessions.create({
280
+ sessionId: "empty_snapshot_session",
281
+ });
282
+ await session.snapshot();
283
+ } finally {
284
+ await first_agent.dispose();
285
+ }
286
+
287
+ const restarted_agent = new Agent({
288
+ id: "empty_snapshot_agent",
289
+ path: agent_path,
290
+ model,
291
+ instruction: ["instruction:must-not-appear"],
292
+ });
293
+ try {
294
+ const session = await restarted_agent.sessions.get("empty_snapshot_session");
295
+ const system = await session.system();
296
+ assert.doesNotMatch(
297
+ system.blocks.map((block) => block.content).join("\n"),
298
+ /instruction:must-not-appear/,
299
+ );
300
+ } finally {
301
+ await restarted_agent.dispose();
302
+ await fs.rm(agent_path, { recursive: true, force: true });
303
+ }
304
+ });
305
+
155
306
  test("running session model changes apply with steer at the next Session step", async () => {
156
307
  const agent_path = await fs.mkdtemp(
157
308
  path.join(os.tmpdir(), "downcity-session-step-boundary-"),
@@ -49,6 +49,24 @@ class PausingAssistantMessageStore extends JsonlSessionMessageStore {
49
49
  }
50
50
  }
51
51
 
52
+ /** 可让下一次 Assistant 草稿更新失败,用于验证持久化失败边界。 */
53
+ class FailingAssistantMessageStore extends JsonlSessionMessageStore {
54
+ next_assistant_error = null;
55
+
56
+ fail_next_assistant_write(message) {
57
+ this.next_assistant_error = new Error(message);
58
+ }
59
+
60
+ async write_assistant_message(message) {
61
+ const error = this.next_assistant_error;
62
+ if (error) {
63
+ this.next_assistant_error = null;
64
+ throw error;
65
+ }
66
+ await super.write_assistant_message(message);
67
+ }
68
+ }
69
+
52
70
  async function create_recorder(
53
71
  session_id = "session-recorder-test",
54
72
  create_store = (options) => new JsonlSessionMessageStore(options),
@@ -703,7 +721,7 @@ test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
703
721
  );
704
722
  assert.equal(approval_broker.list().length, 0);
705
723
 
706
- await writer.prepare_tool_input({
724
+ const tool_input_ready = writer.prepare_tool_input({
707
725
  tool_call_id: "call-ready-barrier",
708
726
  tool_name: "shell_exec",
709
727
  input: {
@@ -712,6 +730,14 @@ test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
712
730
  reason: "Inspect requested desktop files",
713
731
  },
714
732
  });
733
+ await writer.flush();
734
+ assert.deepEqual(recorder.get_message(writer.message_id).parts, []);
735
+ await writer.apply_chunk({
736
+ type: "tool-input-start",
737
+ toolCallId: "call-ready-barrier",
738
+ toolName: "shell_exec",
739
+ });
740
+ await tool_input_ready;
715
741
  const approval_handle = await approval_broker.request(approval_input);
716
742
 
717
743
  const tool = recorder.get_message(writer.message_id).parts[0];
@@ -734,7 +760,7 @@ test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
734
760
  events
735
761
  .filter((event) => event.variant === "part" && event.type === "tool")
736
762
  .map((event) => event.part.state),
737
- ["ready", "approval-required", "failed"],
763
+ ["input-streaming", "ready", "approval-required", "failed"],
738
764
  );
739
765
  });
740
766
 
@@ -749,6 +775,11 @@ test("流式更新与 Approval 共享 Assistant revision 写队列", async () =>
749
775
  turn_id: "turn-approval-race",
750
776
  segment_index: 1,
751
777
  });
778
+ await writer.apply_chunk({
779
+ type: "tool-input-start",
780
+ toolCallId: "call-approval-race",
781
+ toolName: "shell_exec",
782
+ });
752
783
  await writer.prepare_tool_input({
753
784
  tool_call_id: "call-approval-race",
754
785
  tool_name: "shell_exec",
@@ -789,7 +820,7 @@ test("流式更新与 Approval 共享 Assistant revision 写队列", async () =>
789
820
  const assistant = recorder.get_message(writer.message_id);
790
821
  const tool = assistant.parts.find((part) => part.type === "tool");
791
822
  const text_part = assistant.parts.find((part) => part.type === "text");
792
- assert.equal(assistant.revision, 6);
823
+ assert.equal(assistant.revision, 7);
793
824
  assert.equal(tool.state, "approval-required");
794
825
  assert.equal(tool.approval.approval_id, approval_handle.approval_id);
795
826
  assert.equal(tool.input.cmd, "ls -la ~");
@@ -798,7 +829,7 @@ test("流式更新与 Approval 共享 Assistant revision 写队列", async () =>
798
829
  events
799
830
  .filter((event) => event.message_id === writer.message_id)
800
831
  .map((event) => event.revision),
801
- [1, 2, 3, 4, 5, 6],
832
+ [1, 2, 3, 4, 5, 6, 7],
802
833
  );
803
834
  assert.equal(
804
835
  events.some(
@@ -1017,6 +1048,151 @@ test("空 Text Start 不会抢占后续 Tool 的真实顺序", async () => {
1017
1048
  );
1018
1049
  });
1019
1050
 
1051
+ test("Tool 执行先到时等待 stream 固定 canonical Part 顺序", async () => {
1052
+ const { recorder, file_path } = await create_recorder("pending-tool-input-order-test");
1053
+ const writer = await recorder.open_assistant_message({
1054
+ turn_id: "turn-pending-tool-input",
1055
+ segment_index: 1,
1056
+ });
1057
+
1058
+ await writer.begin_step();
1059
+ const tool_input_ready = writer.prepare_tool_input({
1060
+ tool_call_id: "call-pending",
1061
+ tool_name: "search",
1062
+ input: { query: "downcity" },
1063
+ });
1064
+ await writer.flush();
1065
+ assert.deepEqual(recorder.get_message(writer.message_id).parts, []);
1066
+
1067
+ await writer.apply_chunk({ type: "text-start", id: "text-0" });
1068
+ await writer.apply_chunk({ type: "text-delta", id: "text-0", delta: "第一段" });
1069
+ await writer.apply_chunk({ type: "text-end", id: "text-0" });
1070
+ await writer.apply_chunk({ type: "text-start", id: "text-1" });
1071
+ await writer.apply_chunk({ type: "text-delta", id: "text-1", delta: "第二段" });
1072
+ await writer.apply_chunk({ type: "text-end", id: "text-1" });
1073
+ await writer.apply_chunk({
1074
+ type: "tool-input-start",
1075
+ toolCallId: "call-pending",
1076
+ toolName: "search",
1077
+ });
1078
+ await tool_input_ready;
1079
+ await writer.apply_chunk({
1080
+ type: "tool-input-available",
1081
+ toolCallId: "call-pending",
1082
+ toolName: "search",
1083
+ input: { query: "downcity" },
1084
+ });
1085
+ await writer.apply_chunk({
1086
+ type: "tool-output-available",
1087
+ toolCallId: "call-pending",
1088
+ output: "result",
1089
+ });
1090
+ await writer.finish_step([
1091
+ {
1092
+ part_id: "text-final-0",
1093
+ sequence: 1,
1094
+ type: "text",
1095
+ text: "第一段",
1096
+ state: "done",
1097
+ },
1098
+ {
1099
+ part_id: "text-final-1",
1100
+ sequence: 2,
1101
+ type: "text",
1102
+ text: "第二段",
1103
+ state: "done",
1104
+ },
1105
+ {
1106
+ part_id: "call-pending",
1107
+ sequence: 3,
1108
+ type: "tool",
1109
+ tool_call_id: "call-pending",
1110
+ tool_name: "search",
1111
+ state: "completed",
1112
+ input: { query: "downcity" },
1113
+ output: "result",
1114
+ },
1115
+ ]);
1116
+ await writer.complete();
1117
+
1118
+ const assistant = (await read_jsonl(file_path))[0];
1119
+ assert.deepEqual(assistant.parts.map((part) => part.type), ["text", "text", "tool"]);
1120
+ assert.deepEqual(assistant.parts.map((part) => part.sequence), [1, 2, 3]);
1121
+ assert.deepEqual(assistant.parts[2].input, { query: "downcity" });
1122
+ });
1123
+
1124
+ test("step abort 与 writer close 会拒绝未释放的 Tool 输入屏障", async () => {
1125
+ const { recorder } = await create_recorder("pending-tool-input-cleanup-test");
1126
+ const aborted_writer = await recorder.open_assistant_message({
1127
+ turn_id: "turn-aborted-tool-input",
1128
+ segment_index: 1,
1129
+ });
1130
+ await aborted_writer.begin_step();
1131
+ const aborted_input = assert.rejects(
1132
+ aborted_writer.prepare_tool_input({
1133
+ tool_call_id: "call-aborted",
1134
+ tool_name: "search",
1135
+ input: { query: "aborted" },
1136
+ }),
1137
+ /canonical step was aborted: call-aborted/,
1138
+ );
1139
+ await aborted_writer.flush();
1140
+ await aborted_writer.abort_step();
1141
+ await aborted_input;
1142
+ await aborted_writer.fail("aborted");
1143
+
1144
+ const closed_writer = await recorder.open_assistant_message({
1145
+ turn_id: "turn-closed-tool-input",
1146
+ segment_index: 2,
1147
+ });
1148
+ const closed_input = assert.rejects(
1149
+ closed_writer.prepare_tool_input({
1150
+ tool_call_id: "call-closed",
1151
+ tool_name: "search",
1152
+ input: { query: "closed" },
1153
+ }),
1154
+ /writer closed with status stopped: call-closed/,
1155
+ );
1156
+ await closed_writer.flush();
1157
+ await closed_writer.stop();
1158
+ await closed_input;
1159
+ });
1160
+
1161
+ test("Tool Part 持久化失败时不会释放对应执行等待", async () => {
1162
+ const { recorder, store } = await create_recorder(
1163
+ "tool-part-persistence-gate-test",
1164
+ (options) => new FailingAssistantMessageStore(options),
1165
+ );
1166
+ const writer = await recorder.open_assistant_message({
1167
+ turn_id: "turn-tool-part-persistence",
1168
+ segment_index: 1,
1169
+ });
1170
+ await writer.begin_step();
1171
+ const tool_input = assert.rejects(
1172
+ writer.prepare_tool_input({
1173
+ tool_call_id: "call-persistence-failed",
1174
+ tool_name: "search",
1175
+ input: { query: "downcity" },
1176
+ }),
1177
+ /canonical step was aborted: call-persistence-failed/,
1178
+ );
1179
+
1180
+ store.fail_next_assistant_write("disk full");
1181
+ await assert.rejects(
1182
+ writer.apply_chunk({
1183
+ type: "tool-input-start",
1184
+ toolCallId: "call-persistence-failed",
1185
+ toolName: "search",
1186
+ }),
1187
+ /disk full/,
1188
+ );
1189
+ assert.deepEqual(recorder.get_message(writer.message_id).parts, []);
1190
+
1191
+ await writer.abort_step();
1192
+ await tool_input;
1193
+ await writer.fail("disk full");
1194
+ });
1195
+
1020
1196
  test("step 最终快照缺少 canonical Tool chunk 时拒绝猜测顺序", async () => {
1021
1197
  const { recorder, events, file_path } = await create_recorder("final-reconcile-order-test");
1022
1198
  const writer = await recorder.open_assistant_message({ turn_id: "turn-1", segment_index: 1 });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @file 验证 canonical Tool Part Gate 按 Tool Call 独立协调等待与释放。
3
+ */
4
+
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+
8
+ import { SessionToolPartGate } from "../bin/session/messages/SessionToolPartGate.js";
9
+
10
+ test("不同 Tool Call 独立释放,不形成全局执行锁", async () => {
11
+ const gate = new SessionToolPartGate();
12
+ let tool_a_ready = false;
13
+ let tool_b_ready = false;
14
+ const tool_a = gate.wait_until_available("call-a").then(() => {
15
+ tool_a_ready = true;
16
+ });
17
+ const tool_b = gate.wait_until_available("call-b").then(() => {
18
+ tool_b_ready = true;
19
+ });
20
+
21
+ gate.mark_available("call-a");
22
+ await tool_a;
23
+ assert.equal(tool_a_ready, true);
24
+ assert.equal(tool_b_ready, false);
25
+
26
+ gate.mark_available("call-b");
27
+ await tool_b;
28
+ assert.equal(tool_b_ready, true);
29
+ await gate.wait_until_available("call-a");
30
+ });
31
+
32
+ test("reject_pending 只清理当前等待,Gate 可用于后续 step", async () => {
33
+ const gate = new SessionToolPartGate();
34
+ const aborted = assert.rejects(
35
+ gate.wait_until_available("call-aborted"),
36
+ /step aborted: call-aborted/,
37
+ );
38
+ gate.reject_pending("step aborted");
39
+ await aborted;
40
+
41
+ const next_step = gate.wait_until_available("call-next");
42
+ gate.mark_available("call-next");
43
+ await next_step;
44
+ });
45
+
46
+ test("close 拒绝当前及后续 Tool Part 等待", async () => {
47
+ const gate = new SessionToolPartGate();
48
+ const pending = assert.rejects(
49
+ gate.wait_until_available("call-pending"),
50
+ /writer closed: call-pending/,
51
+ );
52
+ gate.close("writer closed");
53
+ await pending;
54
+ await assert.rejects(
55
+ gate.wait_until_available("call-future"),
56
+ /writer closed/,
57
+ );
58
+ });
@@ -159,7 +159,6 @@ export class Agent {
159
159
  setInstruction(input: string | string[]): void {
160
160
  const next_instruction = normalizeInstructionInput(input);
161
161
  this.instruction.splice(0, this.instruction.length, ...next_instruction);
162
- this.sessions.broadcast_instruction(this.instruction, generateId());
163
162
  }
164
163
 
165
164
  /**
@@ -146,23 +146,6 @@ export class AgentSessions implements AgentSessionsContract<AgentSession> {
146
146
  return this.list_executing_session_ids().length;
147
147
  }
148
148
 
149
- /**
150
- * 把 Agent instruction 修改广播到已有 Session 的统一输入队列。
151
- */
152
- broadcast_instruction(instruction: string[], command_id: string): void {
153
- const instruction_blocks = createInstructionSystemBlocks(
154
- instruction,
155
- this.project_root,
156
- );
157
- for (const session of this.sessions_by_id.values()) {
158
- session.enqueue_agent_command({
159
- type: "instruction",
160
- command_id,
161
- instruction_blocks,
162
- });
163
- }
164
- }
165
-
166
149
  /**
167
150
  * 把 Agent env 修改广播到已有 Session 的统一输入队列。
168
151
  */
@@ -198,9 +181,7 @@ export class AgentSessions implements AgentSessionsContract<AgentSession> {
198
181
  * 获取或创建一个 session runtime port。
199
182
  */
200
183
  runtime(session_id: string): SessionPort {
201
- return this.get_or_create_session({
202
- session_id,
203
- }).getRuntimePort();
184
+ return this.get_or_create_session({ session_id }).getRuntimePort();
204
185
  }
205
186
 
206
187
  /**
@@ -436,7 +417,7 @@ export class AgentSessions implements AgentSessionsContract<AgentSession> {
436
417
  sessionId: resolved_session_id,
437
418
  tools: this.tools,
438
419
  logger: this.logger,
439
- getInstructionSystemBlocks: () => this.load_instruction_system_blocks(),
420
+ instruction_system_blocks: this.load_instruction_system_blocks(),
440
421
  getAgentEnv: () => this.get_agent_env(),
441
422
  getAgentModel: () => this.get_agent_model(),
442
423
  get_agent_plugins: () => this.get_agent_plugins(),