@downcity/agent 1.1.317 → 1.1.323

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 (138) hide show
  1. package/bin/agent/Agent.d.ts.map +1 -1
  2. package/bin/agent/Agent.js +1 -0
  3. package/bin/agent/Agent.js.map +1 -1
  4. package/bin/agent/AgentSessions.d.ts +2 -0
  5. package/bin/agent/AgentSessions.d.ts.map +1 -1
  6. package/bin/agent/AgentSessions.js +9 -0
  7. package/bin/agent/AgentSessions.js.map +1 -1
  8. package/bin/executor/messages/SessionAttachmentMapper.d.ts +15 -7
  9. package/bin/executor/messages/SessionAttachmentMapper.d.ts.map +1 -1
  10. package/bin/executor/messages/SessionAttachmentMapper.js +22 -32
  11. package/bin/executor/messages/SessionAttachmentMapper.js.map +1 -1
  12. package/bin/executor/types/SessionHistoryMeta.d.ts +2 -0
  13. package/bin/executor/types/SessionHistoryMeta.d.ts.map +1 -1
  14. package/bin/index.d.ts +2 -1
  15. package/bin/index.d.ts.map +1 -1
  16. package/bin/index.js.map +1 -1
  17. package/bin/remote/RemoteSession.d.ts +2 -2
  18. package/bin/remote/RemoteSession.d.ts.map +1 -1
  19. package/bin/remote/RemoteSession.js +2 -2
  20. package/bin/remote/RemoteSession.js.map +1 -1
  21. package/bin/remote/RemoteTransport.d.ts +2 -2
  22. package/bin/remote/RemoteTransport.d.ts.map +1 -1
  23. package/bin/remote/transports/HttpRemoteAgentTransport.d.ts +2 -2
  24. package/bin/remote/transports/HttpRemoteAgentTransport.d.ts.map +1 -1
  25. package/bin/remote/transports/HttpRemoteAgentTransport.js +2 -2
  26. package/bin/remote/transports/HttpRemoteAgentTransport.js.map +1 -1
  27. package/bin/remote/transports/RpcClient.d.ts +2 -2
  28. package/bin/remote/transports/RpcClient.d.ts.map +1 -1
  29. package/bin/remote/transports/RpcClient.js +2 -2
  30. package/bin/remote/transports/RpcClient.js.map +1 -1
  31. package/bin/remote/transports/RpcRemoteAgentTransport.d.ts +2 -2
  32. package/bin/remote/transports/RpcRemoteAgentTransport.d.ts.map +1 -1
  33. package/bin/remote/transports/RpcRemoteAgentTransport.js +2 -2
  34. package/bin/remote/transports/RpcRemoteAgentTransport.js.map +1 -1
  35. package/bin/session/Session.d.ts +6 -4
  36. package/bin/session/Session.d.ts.map +1 -1
  37. package/bin/session/Session.js +56 -31
  38. package/bin/session/Session.js.map +1 -1
  39. package/bin/session/SessionLoop.d.ts.map +1 -1
  40. package/bin/session/SessionLoop.js +6 -4
  41. package/bin/session/SessionLoop.js.map +1 -1
  42. package/bin/session/SessionMessages.d.ts +9 -3
  43. package/bin/session/SessionMessages.d.ts.map +1 -1
  44. package/bin/session/SessionMessages.js +35 -21
  45. package/bin/session/SessionMessages.js.map +1 -1
  46. package/bin/session/SessionState.d.ts +23 -10
  47. package/bin/session/SessionState.d.ts.map +1 -1
  48. package/bin/session/SessionState.js +129 -51
  49. package/bin/session/SessionState.js.map +1 -1
  50. package/bin/session/SessionTitle.d.ts +4 -0
  51. package/bin/session/SessionTitle.d.ts.map +1 -1
  52. package/bin/session/SessionTitle.js +5 -1
  53. package/bin/session/SessionTitle.js.map +1 -1
  54. package/bin/session/messages/SessionAssistantMessageWriter.d.ts +1 -1
  55. package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
  56. package/bin/session/messages/SessionAssistantMessageWriter.js +4 -4
  57. package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
  58. package/bin/session/runtime/SessionTitleTask.d.ts +35 -0
  59. package/bin/session/runtime/SessionTitleTask.d.ts.map +1 -0
  60. package/bin/session/runtime/SessionTitleTask.js +72 -0
  61. package/bin/session/runtime/SessionTitleTask.js.map +1 -0
  62. package/bin/session/storage/Metadata.d.ts.map +1 -1
  63. package/bin/session/storage/Metadata.js +7 -0
  64. package/bin/session/storage/Metadata.js.map +1 -1
  65. package/bin/types/agent/SessionActor.d.ts +3 -3
  66. package/bin/types/agent/SessionActor.d.ts.map +1 -1
  67. package/bin/types/agent/SessionTypes.d.ts +20 -0
  68. package/bin/types/agent/SessionTypes.d.ts.map +1 -1
  69. package/bin/types/rpc/RpcProtocol.d.ts +2 -1
  70. package/bin/types/rpc/RpcProtocol.d.ts.map +1 -1
  71. package/bin/types/sdk/AgentSessionPrompt.d.ts +22 -0
  72. package/bin/types/sdk/AgentSessionPrompt.d.ts.map +1 -1
  73. package/bin/types/sdk/AgentSessionPrompt.js.map +1 -1
  74. package/bin/types/session/SessionCommand.d.ts +2 -0
  75. package/bin/types/session/SessionCommand.d.ts.map +1 -1
  76. package/bin/types/session/SessionLocalState.d.ts +3 -0
  77. package/bin/types/session/SessionLocalState.d.ts.map +1 -1
  78. package/bin/types/session/SessionMessages.d.ts +5 -0
  79. package/bin/types/session/SessionMessages.d.ts.map +1 -1
  80. package/bin/types/session/SessionOptions.d.ts +2 -0
  81. package/bin/types/session/SessionOptions.d.ts.map +1 -1
  82. package/bin/types/store/SessionAttachmentStore.d.ts +29 -0
  83. package/bin/types/store/SessionAttachmentStore.d.ts.map +1 -0
  84. package/bin/types/store/SessionAttachmentStore.js +15 -0
  85. package/bin/types/store/SessionAttachmentStore.js.map +1 -0
  86. package/bin/types/store/SessionStore.d.ts +3 -0
  87. package/bin/types/store/SessionStore.d.ts.map +1 -1
  88. package/bin/workspace/store/LocalSessionAttachmentStore.d.ts +25 -0
  89. package/bin/workspace/store/LocalSessionAttachmentStore.d.ts.map +1 -0
  90. package/bin/workspace/store/LocalSessionAttachmentStore.js +72 -0
  91. package/bin/workspace/store/LocalSessionAttachmentStore.js.map +1 -0
  92. package/bin/workspace/store/LocalSessionStore.d.ts +3 -0
  93. package/bin/workspace/store/LocalSessionStore.d.ts.map +1 -1
  94. package/bin/workspace/store/LocalSessionStore.js +8 -1
  95. package/bin/workspace/store/LocalSessionStore.js.map +1 -1
  96. package/bin/workspace/store/LocalStorePaths.d.ts +6 -0
  97. package/bin/workspace/store/LocalStorePaths.d.ts.map +1 -1
  98. package/bin/workspace/store/LocalStorePaths.js +8 -0
  99. package/bin/workspace/store/LocalStorePaths.js.map +1 -1
  100. package/package.json +6 -6
  101. package/scripts/remote-session-reconnect.test.mjs +25 -0
  102. package/scripts/session-attachments.test.mjs +101 -0
  103. package/scripts/session-config-turn-boundary.test.mjs +185 -0
  104. package/scripts/session-list-title.test.mjs +6 -0
  105. package/scripts/session-title-event.test.mjs +67 -3
  106. package/scripts/session-turn-failure.test.mjs +34 -1
  107. package/src/agent/Agent.ts +1 -0
  108. package/src/agent/AgentSessions.ts +10 -0
  109. package/src/executor/messages/SessionAttachmentMapper.ts +24 -37
  110. package/src/executor/types/SessionHistoryMeta.ts +2 -0
  111. package/src/index.ts +2 -0
  112. package/src/remote/RemoteSession.ts +6 -2
  113. package/src/remote/RemoteTransport.ts +6 -1
  114. package/src/remote/transports/HttpRemoteAgentTransport.ts +3 -1
  115. package/src/remote/transports/RpcClient.ts +3 -1
  116. package/src/remote/transports/RpcRemoteAgentTransport.ts +3 -1
  117. package/src/session/Session.ts +65 -32
  118. package/src/session/SessionLoop.ts +6 -3
  119. package/src/session/SessionMessages.ts +61 -20
  120. package/src/session/SessionState.ts +150 -59
  121. package/src/session/SessionTitle.ts +13 -1
  122. package/src/session/messages/SessionAssistantMessageWriter.ts +7 -3
  123. package/src/session/runtime/SessionTitleTask.ts +86 -0
  124. package/src/session/storage/Metadata.ts +10 -0
  125. package/src/types/agent/SessionActor.ts +3 -2
  126. package/src/types/agent/SessionTypes.ts +22 -0
  127. package/src/types/rpc/RpcProtocol.ts +6 -1
  128. package/src/types/sdk/AgentSessionPrompt.ts +22 -0
  129. package/src/types/session/SessionCommand.ts +2 -0
  130. package/src/types/session/SessionLocalState.ts +4 -0
  131. package/src/types/session/SessionMessages.ts +5 -0
  132. package/src/types/session/SessionOptions.ts +3 -0
  133. package/src/types/store/SessionAttachmentStore.ts +29 -0
  134. package/src/types/store/SessionStore.ts +4 -0
  135. package/src/workspace/store/LocalSessionAttachmentStore.ts +96 -0
  136. package/src/workspace/store/LocalSessionStore.ts +13 -0
  137. package/src/workspace/store/LocalStorePaths.ts +16 -0
  138. package/tsconfig.tsbuildinfo +1 -1
@@ -600,6 +600,18 @@ test("running session approval mode changes stay queued until the next Session s
600
600
  },
601
601
  });
602
602
 
603
+ const cached_session = await agent.sessions.get(session.id);
604
+ assert.equal(cached_session, session);
605
+ assert.deepEqual(await cached_session.status(), {
606
+ session_id: session.id,
607
+ state: "running",
608
+ active_turn_id: first_turn.id,
609
+ security: {
610
+ approval_mode: "always-allow",
611
+ effective_approval_mode: "ask",
612
+ },
613
+ });
614
+
603
615
  release_first_provider_request.resolve();
604
616
  assert.equal((await first_turn.finished).success, true);
605
617
  assert.deepEqual(await session.status(), {
@@ -639,6 +651,179 @@ test("running session approval mode changes stay queued until the next Session s
639
651
  }
640
652
  });
641
653
 
654
+ test("session set options independently control Action persistence and Mutation publication", async () => {
655
+ const agent_path = await fs.mkdtemp(
656
+ path.join(os.tmpdir(), "downcity-session-set-options-"),
657
+ );
658
+ const first_model = new MockLanguageModelV3({
659
+ modelId: "set-options-first-model",
660
+ doStream: async () => create_stream_text_result("first"),
661
+ });
662
+ const second_model = new MockLanguageModelV3({
663
+ modelId: "set-options-second-model",
664
+ doStream: async () => create_stream_text_result("second"),
665
+ });
666
+ const agent = new Agent({
667
+ id: "session_set_options_agent",
668
+ workspace: new Workspace({ path: agent_path }),
669
+ model: first_model,
670
+ });
671
+
672
+ try {
673
+ const session = await agent.sessions.create({
674
+ session_id: "session_set_options_session",
675
+ });
676
+ const mutations = [];
677
+ const unsubscribe = session.subscribe((mutation) => {
678
+ mutations.push(mutation);
679
+ });
680
+
681
+ await session.set(
682
+ { model: first_model },
683
+ { persist_action: false, publish_mutation: false },
684
+ );
685
+ assert.equal((await (await session.prompt({ query: "first" })).finished).success, true);
686
+ assert.equal(
687
+ (await session.messages()).items.some(
688
+ (message) =>
689
+ message.type === "action" &&
690
+ message.title === "Session configuration updated",
691
+ ),
692
+ false,
693
+ );
694
+
695
+ mutations.splice(0, mutations.length);
696
+ await session.set(
697
+ { model: second_model },
698
+ { publish_mutation: false },
699
+ );
700
+ assert.equal((await (await session.prompt({ query: "second" })).finished).success, true);
701
+ assert.equal(
702
+ (await session.messages()).items.some(
703
+ (message) =>
704
+ message.type === "action" &&
705
+ message.description === "model: set-options-second-model",
706
+ ),
707
+ true,
708
+ );
709
+ assert.equal(
710
+ mutations.some(
711
+ (mutation) =>
712
+ mutation.variant === "message" &&
713
+ mutation.type === "action",
714
+ ),
715
+ false,
716
+ );
717
+
718
+ const before_duplicate_count = (await session.messages()).items.filter(
719
+ (message) =>
720
+ message.type === "action" &&
721
+ message.title === "Session configuration updated",
722
+ ).length;
723
+ await session.set({ model: second_model });
724
+ assert.equal((await (await session.prompt({ query: "duplicate" })).finished).success, true);
725
+ assert.equal(
726
+ (await session.messages()).items.filter(
727
+ (message) =>
728
+ message.type === "action" &&
729
+ message.title === "Session configuration updated",
730
+ ).length,
731
+ before_duplicate_count,
732
+ );
733
+
734
+ await assert.rejects(
735
+ session.set(
736
+ { security: { approval_mode: "always-allow" } },
737
+ { persist_action: false, publish_mutation: true },
738
+ ),
739
+ /publish_mutation requires persist_action/,
740
+ );
741
+ unsubscribe();
742
+ } finally {
743
+ await agent.dispose();
744
+ await fs.rm(agent_path, { recursive: true, force: true });
745
+ }
746
+ });
747
+
748
+ test("restored Session rebinds the same model without emitting a configuration Mutation", async () => {
749
+ const agent_path = await fs.mkdtemp(
750
+ path.join(os.tmpdir(), "downcity-session-config-restore-"),
751
+ );
752
+ const create_model = () => new MockLanguageModelV3({
753
+ modelId: "restored-session-model",
754
+ doStream: async () => create_stream_text_result("restored"),
755
+ });
756
+ const first_agent = new Agent({
757
+ id: "session_config_restore_agent",
758
+ workspace: new Workspace({ path: agent_path }),
759
+ model: create_model(),
760
+ });
761
+
762
+ try {
763
+ const session = await first_agent.sessions.create({
764
+ session_id: "session_config_restore_session",
765
+ });
766
+ await session.set({
767
+ model: create_model(),
768
+ security: { approval_mode: "always-allow" },
769
+ });
770
+ assert.equal((await (await session.prompt({ query: "persist" })).finished).success, true);
771
+ } finally {
772
+ await first_agent.dispose();
773
+ }
774
+
775
+ const restored_agent = new Agent({
776
+ id: "session_config_restore_agent",
777
+ workspace: new Workspace({ path: agent_path }),
778
+ model: create_model(),
779
+ });
780
+ try {
781
+ const session = await restored_agent.sessions.get(
782
+ "session_config_restore_session",
783
+ );
784
+ assert.deepEqual((await session.status()).security, {
785
+ approval_mode: "always-allow",
786
+ effective_approval_mode: "always-allow",
787
+ });
788
+ const before_action_count = (await session.messages()).items.filter(
789
+ (message) =>
790
+ message.type === "action" &&
791
+ message.title === "Session configuration updated",
792
+ ).length;
793
+ const mutations = [];
794
+ const unsubscribe = session.subscribe((mutation) => {
795
+ mutations.push(mutation);
796
+ });
797
+
798
+ await session.set({
799
+ model: create_model(),
800
+ security: { approval_mode: "always-allow" },
801
+ });
802
+ assert.equal((await (await session.prompt({ query: "restore" })).finished).success, true);
803
+ assert.equal(
804
+ (await session.messages()).items.filter(
805
+ (message) =>
806
+ message.type === "action" &&
807
+ message.title === "Session configuration updated",
808
+ ).length,
809
+ before_action_count,
810
+ );
811
+ assert.equal(
812
+ mutations.some(
813
+ (mutation) =>
814
+ mutation.variant === "message" &&
815
+ mutation.type === "action" &&
816
+ mutation.message.title === "Session configuration updated",
817
+ ),
818
+ false,
819
+ );
820
+ unsubscribe();
821
+ } finally {
822
+ await restored_agent.dispose();
823
+ await fs.rm(agent_path, { recursive: true, force: true });
824
+ }
825
+ });
826
+
642
827
  test("config remains effective when its action message cannot be persisted", async () => {
643
828
  const agent_path = await fs.mkdtemp(
644
829
  path.join(os.tmpdir(), "downcity-config-action-observability-"),
@@ -72,6 +72,12 @@ async function create_agent_with_titled_session(input) {
72
72
  await session.append_user_message({
73
73
  text: input.first_user_text,
74
74
  });
75
+ const title_deadline = Date.now() + 1000;
76
+ while (Date.now() < title_deadline) {
77
+ if ((await session.get_info()).title === input.title) break;
78
+ await new Promise((resolve) => setTimeout(resolve, 10));
79
+ }
80
+ assert.equal((await session.get_info()).title, input.title);
75
81
 
76
82
  return {
77
83
  agent,
@@ -79,6 +79,30 @@ function create_failing_title_model() {
79
79
  });
80
80
  }
81
81
 
82
+ function create_delayed_title_model(title_text) {
83
+ let resolve_started;
84
+ let resolve_release;
85
+ const started = new Promise((resolve) => {
86
+ resolve_started = resolve;
87
+ });
88
+ const released = new Promise((resolve) => {
89
+ resolve_release = resolve;
90
+ });
91
+ const model = new MockLanguageModelV3({
92
+ modelId: "mock-session-title-delayed-model",
93
+ doStream: async () => {
94
+ resolve_started();
95
+ await released;
96
+ return create_stream_text_result(title_text);
97
+ },
98
+ });
99
+ return {
100
+ model,
101
+ started,
102
+ release: () => resolve_release(),
103
+ };
104
+ }
105
+
82
106
  async function read_log_lines(agent_path) {
83
107
  const logs_path = path.join(agent_path, ".downcity", "logs");
84
108
  const entries = await fs.readdir(logs_path);
@@ -91,6 +115,15 @@ async function read_log_lines(agent_path) {
91
115
  return lines;
92
116
  }
93
117
 
118
+ async function wait_for_title(session, expected_title) {
119
+ const deadline = Date.now() + 1000;
120
+ while (Date.now() < deadline) {
121
+ if ((await session.get_info()).title === expected_title) return;
122
+ await new Promise((resolve) => setTimeout(resolve, 10));
123
+ }
124
+ assert.equal((await session.get_info()).title, expected_title);
125
+ }
126
+
94
127
  test("Session keeps title empty when no model is available", async () => {
95
128
  const agent_path = await fs.mkdtemp(
96
129
  path.join(os.tmpdir(), "downcity-agent-session-title-"),
@@ -123,6 +156,29 @@ test("Session keeps title empty when no model is available", async () => {
123
156
  }
124
157
  });
125
158
 
159
+ test("Session title generation does not block user message append", async () => {
160
+ const agent_path = await fs.mkdtemp(
161
+ path.join(os.tmpdir(), "downcity-agent-session-title-async-"),
162
+ );
163
+ const delayed = create_delayed_title_model("异步标题");
164
+ const agent = new Agent({
165
+ id: "title_async_agent",
166
+ workspace: new Workspace({ path: agent_path }),
167
+ model: delayed.model,
168
+ });
169
+ const session = await agent.sessions.create();
170
+
171
+ try {
172
+ await session.append_user_message({ text: "标题生成不应阻塞消息写入" });
173
+ await delayed.started;
174
+ assert.equal((await session.get_info()).title, undefined);
175
+ delayed.release();
176
+ await wait_for_title(session, "异步标题");
177
+ } finally {
178
+ await agent.dispose();
179
+ }
180
+ });
181
+
126
182
  test("Session logs title generation failure without blocking the session", async () => {
127
183
  const agent_path = await fs.mkdtemp(
128
184
  path.join(os.tmpdir(), "downcity-agent-session-title-log-"),
@@ -142,8 +198,16 @@ test("Session logs title generation failure without blocking the session", async
142
198
 
143
199
  assert.equal((await session.get_info()).title, undefined);
144
200
 
145
- await agent.get_logger().save_all_logs();
146
- const log_lines = await read_log_lines(agent_path);
201
+ let log_lines = [];
202
+ const log_deadline = Date.now() + 1000;
203
+ while (Date.now() < log_deadline) {
204
+ await agent.get_logger().save_all_logs();
205
+ log_lines = await read_log_lines(agent_path);
206
+ if (log_lines.some((line) => line.includes("session_title.generate_failed"))) {
207
+ break;
208
+ }
209
+ await new Promise((resolve) => setTimeout(resolve, 10));
210
+ }
147
211
  const title_failure_log = log_lines
148
212
  .map((line) => JSON.parse(line))
149
213
  .find((entry) => entry.message.includes("session_title.generate_failed"));
@@ -196,7 +260,7 @@ test("Session retries title generation after model becomes available", async ()
196
260
  text: "Need another prompt to trigger the retry path",
197
261
  });
198
262
 
199
- assert.equal((await session.get_info()).title, "排查 session 标题");
263
+ await wait_for_title(session, "排查 session 标题");
200
264
  } finally {
201
265
  unsubscribe();
202
266
  await agent.dispose();
@@ -45,8 +45,9 @@ async function create_turn_harness(execute_turn) {
45
45
  compact_history: async () => ({ compacted: false, reason: "nothing_to_compact" }),
46
46
  state: {
47
47
  ensure_runnable: async () => {},
48
- ensure_title_from_history: async () => {},
48
+ schedule_title_generation: () => {},
49
49
  touch_metadata: async () => {},
50
+ touch_metadata_in_background: () => {},
50
51
  },
51
52
  messages,
52
53
  events: new SessionEventHub(),
@@ -137,6 +138,38 @@ test("Provider 在部分输出后失败时保留 failed Assistant 并追加 Erro
137
138
  assert.equal(page.items[2].message, "stream interrupted");
138
139
  });
139
140
 
141
+ test("Assistant 失败收口时不会遗留 input-streaming Tool Part", async () => {
142
+ const { messages, turn } = await create_turn_harness(async (turn_context) => {
143
+ await turn_context.output.assistant.begin_step();
144
+ await turn_context.output.assistant.write_chunk({
145
+ type: "tool-input-start",
146
+ toolCallId: "call-1",
147
+ toolName: "shell_exec",
148
+ });
149
+ await turn_context.output.assistant.write_chunk({
150
+ type: "tool-input-delta",
151
+ toolCallId: "call-1",
152
+ inputTextDelta: '{"cmd":"pwd"}',
153
+ });
154
+ return {
155
+ success: false,
156
+ text: "",
157
+ error: "stream interrupted",
158
+ deferred_persisted_user_messages: [],
159
+ };
160
+ });
161
+
162
+ const handle = await turn.prompt({ query: "hello" });
163
+ await handle.finished;
164
+ const page = await messages.list_messages();
165
+ const assistant = page.items.find((message) => message.type === "assistant");
166
+
167
+ assert.equal(assistant?.status, "failed");
168
+ assert.equal(assistant?.parts[0]?.type, "tool");
169
+ assert.equal(assistant?.parts[0]?.state, "failed");
170
+ assert.equal(assistant?.parts[0]?.error, "stream interrupted");
171
+ });
172
+
140
173
  test("Turn 使用 step canonical chunks 保持 Tool 与最终正文顺序", async () => {
141
174
  const { messages, turn } = await create_turn_harness(async (turn_context) => {
142
175
  await turn_context.output.assistant.begin_step();
@@ -206,6 +206,7 @@ export class Agent {
206
206
  async dispose(): Promise<void> {
207
207
  this.unsubscribe_workspace_env();
208
208
  this.unsubscribe_plugin_change();
209
+ this.sessions.dispose_title_generation();
209
210
  try {
210
211
  await this.state.dispose();
211
212
  } finally {
@@ -133,6 +133,13 @@ export class AgentSessions implements AgentSessionsContract<AgentSession> {
133
133
  return this.list_executing_session_ids().length;
134
134
  }
135
135
 
136
+ /** 释放全部缓存 Session 的标题后台任务。 */
137
+ dispose_title_generation(): void {
138
+ for (const session of this.sessions_by_id.values()) {
139
+ session.dispose_title_generation?.();
140
+ }
141
+ }
142
+
136
143
  /**
137
144
  * 把 Agent env 修改广播到已有 Session 的统一输入队列。
138
145
  */
@@ -228,6 +235,7 @@ export class AgentSessions implements AgentSessionsContract<AgentSession> {
228
235
  if (cached?.is_executing()) {
229
236
  await cached.stop();
230
237
  }
238
+ cached?.dispose_title_generation?.();
231
239
  const existed = await this.store.remove_session(resolved_session_id);
232
240
  this.sessions_by_id.delete(resolved_session_id);
233
241
  return existed;
@@ -245,6 +253,7 @@ export class AgentSessions implements AgentSessionsContract<AgentSession> {
245
253
  if (cached?.is_executing()) {
246
254
  throw new Error(`Session "${resolved_session_id}" is currently executing`);
247
255
  }
256
+ cached?.dispose_title_generation?.();
248
257
  const existed = await this.store.clear_session_messages(resolved_session_id);
249
258
  this.sessions_by_id.delete(resolved_session_id);
250
259
  return existed;
@@ -279,6 +288,7 @@ export class AgentSessions implements AgentSessionsContract<AgentSession> {
279
288
  }
280
289
 
281
290
  const result = await this.store.archive_session(session_id);
291
+ this.sessions_by_id.get(session_id)?.dispose_title_generation?.();
282
292
  this.sessions_by_id.delete(session_id);
283
293
  return result;
284
294
  }
@@ -3,9 +3,17 @@
3
3
  *
4
4
  * 关键点(中文)
5
5
  * - 兼容 Telegram / Feishu / TUI 等统一的 `<file>` 协议入口。
6
- * - 仅在本轮执行的内存消息上追加 file parts,不修改持久化历史。
7
- * - 当前只为图片与 PDF 注入 file part,保持多模态模型可直接消费。
6
+ * - Data URL 附件先由 Session Attachment Store 落盘,Message 只保存文件路径。
7
+ * - 模型执行阶段再读取本地文件,并转换为模型可消费的数据格式。
8
8
  * - 历史中的相对路径与旧版 `file://` 会在喂给模型前临时 hydrate。
9
+ *
10
+ * 输入到输出(中文):
11
+ * `file.url = data:<media-type>;base64,...`
12
+ * → Session Attachment Store 解码并写入附件文件
13
+ * → `file.url = .downcity/.../attachments/att_<id>.<ext>`
14
+ * → Message 持久化路径引用
15
+ * → 模型执行前读取文件并恢复为 Data URL
16
+ * → `convertToModelMessages()` 生成最终 ModelMessage。
9
17
  */
10
18
 
11
19
  import fs from "fs-extra";
@@ -17,6 +25,7 @@ import {
17
25
  type FileUIPart,
18
26
  } from "ai";
19
27
  import type { SessionUserMessagePart } from "@/types/sdk/AgentSessionPrompt.js";
28
+ import type { SessionAttachmentStore } from "@/types/store/SessionAttachmentStore.js";
20
29
  import type {
21
30
  SessionRecordV1,
22
31
  SessionMessageRecordV1,
@@ -106,42 +115,16 @@ async function hydrateFileUrlPart(
106
115
  }
107
116
  }
108
117
 
109
- async function hydrateFileUrlPartStrict(
110
- part: FileUIPart,
111
- project_root?: string,
112
- ): Promise<FileUIPart> {
113
- const url = String(part.url || "").trim();
114
- const file_path = resolveHydratableFilePath(project_root, url);
115
- if (!file_path) return part;
116
-
117
- try {
118
- const buffer = await fs.readFile(file_path);
119
- const media_type =
120
- String(part.mediaType || "").trim() ||
121
- guessAttachmentMediaTypeFromPath(file_path) ||
122
- "application/octet-stream";
123
- return {
124
- ...part,
125
- mediaType: media_type,
126
- url: buildDataUrl(media_type, buffer),
127
- };
128
- } catch (error) {
129
- const message = error instanceof Error ? error.message : String(error);
130
- throw new Error(`读取本地附件失败:${file_path}。${message}`);
131
- }
132
- }
133
-
134
118
  /**
135
- * 在用户 prompt 入库前,将本地图片 file part 转成 data URL。
119
+ * 在用户 prompt 入库前,将 Data URL file part 保存为 Session 附件路径。
136
120
  *
137
121
  * 关键点(中文)
138
- * - 只处理调用侧直接传入的图片 file part。
139
- * - 已经是 data URL 或远程 URL 的附件保持原样。
140
- * - 本地文件读取失败时直接报错,避免模型请求拿到不可访问的本地路径。
122
+ * - 只有 Data URL 会在此处落盘;远程 URL 和本地路径保持引用不变。
123
+ * - 附件成功落盘后才把 URL 替换为相对 Workspace 根目录的路径。
141
124
  */
142
- export async function hydrate_user_prompt_file_parts(
125
+ export async function persist_user_prompt_file_parts(
143
126
  parts: SessionUserMessagePart[],
144
- project_root?: string,
127
+ attachment_store: SessionAttachmentStore,
145
128
  ): Promise<SessionUserMessagePart[]> {
146
129
  if (!Array.isArray(parts) || parts.length === 0) return [];
147
130
 
@@ -153,14 +136,18 @@ export async function hydrate_user_prompt_file_parts(
153
136
  }
154
137
 
155
138
  const file_part = part as FileUIPart;
156
- const media_type = String(file_part.mediaType || "").trim();
157
- if (!media_type.startsWith("image/")) {
139
+ const url = String(file_part.url || "").trim();
140
+ if (!url.startsWith("data:")) {
158
141
  out.push(part);
159
142
  continue;
160
143
  }
161
144
 
162
- const next_part = await hydrateFileUrlPartStrict(file_part, project_root);
163
- out.push(next_part as SessionUserMessagePart);
145
+ const stored_path = await attachment_store.persist_data_url({
146
+ data_url: url,
147
+ media_type: String(file_part.mediaType || "").trim(),
148
+ ...(file_part.filename ? { filename: file_part.filename } : {}),
149
+ });
150
+ out.push({ ...file_part, url: stored_path } as SessionUserMessagePart);
164
151
  }
165
152
 
166
153
  return out;
@@ -23,6 +23,8 @@ export type SessionHistoryMetaV1 = {
23
23
  title?: string;
24
24
  /** 当前 session 绑定模型的可读标签。 */
25
25
  model_label?: string;
26
+ /** 当前 Session 持久化的 Shell 审批模式。 */
27
+ approval_mode?: "ask" | "always-allow";
26
28
  /** 当前 session 已持久化记录数量,用于列表查询避免扫描完整历史。 */
27
29
  message_count?: number;
28
30
  /** 当前 session 最后一条记录的用户可见摘要,用于列表预览。 */
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export type {
18
18
  SessionMessageStore,
19
19
  SessionStore,
20
20
  } from "./types/store/SessionStore.js";
21
+ export type { SessionAttachmentStore } from "./types/store/SessionAttachmentStore.js";
21
22
  export { create_session_message_store } from "./workspace/store/SessionMessageStoreFactory.js";
22
23
  export type {
23
24
  FileSystem,
@@ -53,6 +54,7 @@ export type {
53
54
  AgentSessionSecurityConfig,
54
55
  AgentSessionSecurityStatus,
55
56
  AgentSessionSetInput,
57
+ AgentSessionSetOptions,
56
58
  AgentSessionStatus,
57
59
  AgentSessionSummary,
58
60
  AgentSessionSummaryPage,
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type {
8
8
  AgentSessionConfigSnapshot,
9
+ AgentSessionSetOptions,
9
10
  AgentSessionForkInput,
10
11
  AgentSessionInfo,
11
12
  AgentSessionStatus,
@@ -159,8 +160,11 @@ export class RemoteSession implements RemoteAgentSession {
159
160
  }
160
161
 
161
162
  /** 更新当前远程 Session 的可序列化动态配置。 */
162
- async set(input: RemoteSessionSetInput): Promise<void> {
163
- await this.transport.set(this.id, input);
163
+ async set(
164
+ input: RemoteSessionSetInput,
165
+ options?: AgentSessionSetOptions,
166
+ ): Promise<void> {
167
+ await this.transport.set(this.id, input, options);
164
168
  }
165
169
 
166
170
  /** 提交当前远程 Session 的 Interaction 用户响应。 */
@@ -16,6 +16,7 @@ import type {
16
16
  AgentCleanArchiveResult,
17
17
  AgentSessionForkInput,
18
18
  AgentSessionInfo,
19
+ AgentSessionSetOptions,
19
20
  AgentSessionStatus,
20
21
  AgentSessionSummaryPage,
21
22
  AgentSessionSystemSnapshot,
@@ -86,7 +87,11 @@ export type RemoteSessionTransport = {
86
87
  /** 读取指定 Session 的运行与安全状态。 */
87
88
  status(session_id: string): Promise<AgentSessionStatus>;
88
89
  /** 更新指定 Session 的可序列化动态配置。 */
89
- set(session_id: string, input: RemoteSessionSetInput): Promise<void>;
90
+ set(
91
+ session_id: string,
92
+ input: RemoteSessionSetInput,
93
+ options?: AgentSessionSetOptions,
94
+ ): Promise<void>;
90
95
  /** 提交指定 Session 的 Interaction 用户响应。 */
91
96
  respond(
92
97
  session_id: string,
@@ -16,6 +16,7 @@ import type {
16
16
  AgentCleanArchiveResult,
17
17
  AgentSessionForkInput,
18
18
  AgentSessionInfo,
19
+ AgentSessionSetOptions,
19
20
  AgentSessionStatus,
20
21
  AgentSessionSummaryPage,
21
22
  AgentSessionSystemSnapshot,
@@ -427,6 +428,7 @@ export class HttpRemoteAgentTransport implements RemoteAgentTransport {
427
428
  async set(
428
429
  session_id: string,
429
430
  input: RemoteSessionSetInput,
431
+ options?: AgentSessionSetOptions,
430
432
  ): Promise<void> {
431
433
  const payload = await read_http_json<{
432
434
  success?: boolean;
@@ -437,7 +439,7 @@ export class HttpRemoteAgentTransport implements RemoteAgentTransport {
437
439
  headers: this.headers({
438
440
  "Content-Type": "application/json",
439
441
  }),
440
- body: JSON.stringify(input),
442
+ body: JSON.stringify({ ...input, ...(options ? { options } : {}) }),
441
443
  });
442
444
  if (payload.success !== true || payload.queued !== true) {
443
445
  throw new Error(String(payload.error || "Remote session set failed"));
@@ -17,6 +17,7 @@ import type {
17
17
  AgentArchiveSessionsResult,
18
18
  AgentCleanArchiveResult,
19
19
  AgentSessionInfo,
20
+ AgentSessionSetOptions,
20
21
  AgentSessionStatus,
21
22
  AgentSessionSummaryPage,
22
23
  AgentSessionSystemSnapshot,
@@ -257,10 +258,11 @@ export class RpcClient {
257
258
  async set_session(
258
259
  session_id: string,
259
260
  input: RemoteSessionSetInput,
261
+ options?: AgentSessionSetOptions,
260
262
  ): Promise<void> {
261
263
  await this.request<{ queued: true }>({
262
264
  method: "sdk.sessions.set",
263
- params: { session_id: session_id, input },
265
+ params: { session_id: session_id, input, ...(options ? { options } : {}) },
264
266
  });
265
267
  }
266
268
 
@@ -16,6 +16,7 @@ import type {
16
16
  AgentCleanArchiveResult,
17
17
  AgentSessionForkInput,
18
18
  AgentSessionInfo,
19
+ AgentSessionSetOptions,
19
20
  AgentSessionStatus,
20
21
  AgentSessionSummaryPage,
21
22
  AgentSessionSystemSnapshot,
@@ -167,8 +168,9 @@ export class RpcRemoteAgentTransport implements RemoteAgentTransport {
167
168
  async set(
168
169
  session_id: string,
169
170
  input: RemoteSessionSetInput,
171
+ options?: AgentSessionSetOptions,
170
172
  ): Promise<void> {
171
- await this.client.set_session(session_id, input);
173
+ await this.client.set_session(session_id, input, options);
172
174
  }
173
175
 
174
176
  async respond(