@downcity/agent 1.1.228 → 1.1.232

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 (157) hide show
  1. package/bin/agent/local/Agent.d.ts +6 -1
  2. package/bin/agent/local/Agent.d.ts.map +1 -1
  3. package/bin/agent/local/Agent.js +30 -3
  4. package/bin/agent/local/Agent.js.map +1 -1
  5. package/bin/agent/local/services/AgentSessions.d.ts +23 -0
  6. package/bin/agent/local/services/AgentSessions.d.ts.map +1 -1
  7. package/bin/agent/local/services/AgentSessions.js +44 -0
  8. package/bin/agent/local/services/AgentSessions.js.map +1 -1
  9. package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts.map +1 -1
  10. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js +0 -7
  11. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js.map +1 -1
  12. package/bin/executor/Executor.d.ts +25 -0
  13. package/bin/executor/Executor.d.ts.map +1 -1
  14. package/bin/executor/Executor.js +36 -0
  15. package/bin/executor/Executor.js.map +1 -1
  16. package/bin/executor/composer/compaction/SessionCompactionComposer.d.ts +4 -0
  17. package/bin/executor/composer/compaction/SessionCompactionComposer.d.ts.map +1 -1
  18. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.d.ts +0 -1
  19. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.d.ts.map +1 -1
  20. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.js +15 -11
  21. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.js.map +1 -1
  22. package/bin/executor/core-engine/CoreEngineRunner.d.ts +11 -0
  23. package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
  24. package/bin/executor/core-engine/CoreEngineRunner.js +12 -8
  25. package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
  26. package/bin/executor/store/history/SessionHistoryStore.d.ts +3 -13
  27. package/bin/executor/store/history/SessionHistoryStore.d.ts.map +1 -1
  28. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js +1 -1
  29. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js.map +1 -1
  30. package/bin/executor/tools/plugin/PluginToolBridge.d.ts.map +1 -1
  31. package/bin/executor/tools/plugin/PluginToolBridge.js +4 -2
  32. package/bin/executor/tools/plugin/PluginToolBridge.js.map +1 -1
  33. package/bin/index.d.ts +2 -1
  34. package/bin/index.d.ts.map +1 -1
  35. package/bin/index.js +1 -1
  36. package/bin/index.js.map +1 -1
  37. package/bin/model/CityModelAdapter.d.ts +6 -0
  38. package/bin/model/CityModelAdapter.d.ts.map +1 -1
  39. package/bin/model/CityModelAdapter.js +13 -0
  40. package/bin/model/CityModelAdapter.js.map +1 -1
  41. package/bin/plugin/core/PluginRegistry.d.ts +22 -1
  42. package/bin/plugin/core/PluginRegistry.d.ts.map +1 -1
  43. package/bin/plugin/core/PluginRegistry.js +46 -4
  44. package/bin/plugin/core/PluginRegistry.js.map +1 -1
  45. package/bin/session/Session.d.ts +11 -1
  46. package/bin/session/Session.d.ts.map +1 -1
  47. package/bin/session/Session.js +76 -11
  48. package/bin/session/Session.js.map +1 -1
  49. package/bin/session/browse/Browse.d.ts +0 -8
  50. package/bin/session/browse/Browse.d.ts.map +1 -1
  51. package/bin/session/browse/Browse.js +72 -57
  52. package/bin/session/browse/Browse.js.map +1 -1
  53. package/bin/session/recorder/JsonlSessionMessageStore.d.ts +69 -16
  54. package/bin/session/recorder/JsonlSessionMessageStore.d.ts.map +1 -1
  55. package/bin/session/recorder/JsonlSessionMessageStore.js +283 -60
  56. package/bin/session/recorder/JsonlSessionMessageStore.js.map +1 -1
  57. package/bin/session/recorder/SessionRecorder.d.ts +20 -2
  58. package/bin/session/recorder/SessionRecorder.d.ts.map +1 -1
  59. package/bin/session/recorder/SessionRecorder.js +91 -46
  60. package/bin/session/recorder/SessionRecorder.js.map +1 -1
  61. package/bin/session/recorder/SessionRecorderCompaction.d.ts +3 -3
  62. package/bin/session/recorder/SessionRecorderCompaction.d.ts.map +1 -1
  63. package/bin/session/recorder/SessionRecorderCompaction.js +50 -62
  64. package/bin/session/recorder/SessionRecorderCompaction.js.map +1 -1
  65. package/bin/session/recorder/SessionRecorderHistoryStore.d.ts +2 -2
  66. package/bin/session/recorder/SessionRecorderHistoryStore.d.ts.map +1 -1
  67. package/bin/session/recorder/SessionRecorderHistoryStore.js +31 -36
  68. package/bin/session/recorder/SessionRecorderHistoryStore.js.map +1 -1
  69. package/bin/session/runtime/SessionPromptRuntime.d.ts +17 -1
  70. package/bin/session/runtime/SessionPromptRuntime.d.ts.map +1 -1
  71. package/bin/session/runtime/SessionPromptRuntime.js +67 -8
  72. package/bin/session/runtime/SessionPromptRuntime.js.map +1 -1
  73. package/bin/session/services/SessionStateService.d.ts +15 -1
  74. package/bin/session/services/SessionStateService.d.ts.map +1 -1
  75. package/bin/session/services/SessionStateService.js +60 -39
  76. package/bin/session/services/SessionStateService.js.map +1 -1
  77. package/bin/session/services/SessionTurnService.d.ts +5 -0
  78. package/bin/session/services/SessionTurnService.d.ts.map +1 -1
  79. package/bin/session/services/SessionTurnService.js +17 -3
  80. package/bin/session/services/SessionTurnService.js.map +1 -1
  81. package/bin/session/services/SessionViewService.d.ts +1 -5
  82. package/bin/session/services/SessionViewService.d.ts.map +1 -1
  83. package/bin/session/services/SessionViewService.js +4 -34
  84. package/bin/session/services/SessionViewService.js.map +1 -1
  85. package/bin/session/storage/Paths.d.ts +3 -15
  86. package/bin/session/storage/Paths.d.ts.map +1 -1
  87. package/bin/session/storage/Paths.js +5 -21
  88. package/bin/session/storage/Paths.js.map +1 -1
  89. package/bin/types/agent/SessionTypes.d.ts +2 -24
  90. package/bin/types/agent/SessionTypes.d.ts.map +1 -1
  91. package/bin/types/executor/SessionRunContext.d.ts +21 -0
  92. package/bin/types/executor/SessionRunContext.d.ts.map +1 -1
  93. package/bin/types/plugin/PluginRuntime.d.ts +25 -0
  94. package/bin/types/plugin/PluginRuntime.d.ts.map +1 -1
  95. package/bin/types/runtime/agent/AgentContext.d.ts +17 -5
  96. package/bin/types/runtime/agent/AgentContext.d.ts.map +1 -1
  97. package/bin/types/runtime/agent/AgentContext.js +29 -6
  98. package/bin/types/runtime/agent/AgentContext.js.map +1 -1
  99. package/bin/types/session/SessionConfigMutation.d.ts +68 -0
  100. package/bin/types/session/SessionConfigMutation.d.ts.map +1 -0
  101. package/bin/types/session/SessionConfigMutation.js +10 -0
  102. package/bin/types/session/SessionConfigMutation.js.map +1 -0
  103. package/bin/types/session/SessionLocalState.d.ts +8 -0
  104. package/bin/types/session/SessionLocalState.d.ts.map +1 -1
  105. package/bin/types/session/SessionMessage.d.ts +16 -12
  106. package/bin/types/session/SessionMessage.d.ts.map +1 -1
  107. package/bin/types/session/SessionMutation.d.ts +1 -1
  108. package/bin/types/session/SessionMutation.js +1 -1
  109. package/bin/types/session/SessionOptions.d.ts +16 -0
  110. package/bin/types/session/SessionOptions.d.ts.map +1 -1
  111. package/bin/types/session/SessionSegment.d.ts +57 -0
  112. package/bin/types/session/SessionSegment.d.ts.map +1 -0
  113. package/bin/types/session/SessionSegment.js +8 -0
  114. package/bin/types/session/SessionSegment.js.map +1 -0
  115. package/package.json +3 -3
  116. package/scripts/city-model-tool-loop.test.mjs +32 -0
  117. package/scripts/session-action-message.test.mjs +59 -13
  118. package/scripts/session-compaction-context-window.test.mjs +95 -0
  119. package/scripts/session-config-turn-boundary.test.mjs +224 -0
  120. package/scripts/session-list-title.test.mjs +21 -3
  121. package/scripts/session-prompt-runtime.test.mjs +85 -0
  122. package/scripts/session-recorder.test.mjs +340 -11
  123. package/src/agent/local/Agent.ts +34 -3
  124. package/src/agent/local/services/AgentSessions.ts +61 -0
  125. package/src/agent/remote/transports/HttpRemoteAgentTransport.ts +0 -5
  126. package/src/executor/Executor.ts +62 -0
  127. package/src/executor/composer/compaction/SessionCompactionComposer.ts +5 -0
  128. package/src/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.ts +16 -14
  129. package/src/executor/core-engine/CoreEngineRunner.ts +24 -8
  130. package/src/executor/store/history/SessionHistoryStore.ts +3 -14
  131. package/src/executor/store/history/jsonl/JsonlSessionHistoryStore.ts +1 -1
  132. package/src/executor/tools/plugin/PluginToolBridge.ts +4 -2
  133. package/src/index.ts +8 -0
  134. package/src/model/CityModelAdapter.ts +15 -0
  135. package/src/plugin/core/PluginRegistry.ts +72 -4
  136. package/src/session/Session.ts +89 -11
  137. package/src/session/browse/Browse.ts +73 -69
  138. package/src/session/recorder/JsonlSessionMessageStore.ts +378 -60
  139. package/src/session/recorder/SessionRecorder.ts +116 -49
  140. package/src/session/recorder/SessionRecorderCompaction.ts +55 -80
  141. package/src/session/recorder/SessionRecorderHistoryStore.ts +29 -49
  142. package/src/session/runtime/SessionPromptRuntime.ts +100 -9
  143. package/src/session/services/SessionStateService.ts +79 -44
  144. package/src/session/services/SessionTurnService.ts +19 -3
  145. package/src/session/services/SessionViewService.ts +4 -50
  146. package/src/session/storage/Paths.ts +5 -38
  147. package/src/types/agent/SessionTypes.ts +2 -24
  148. package/src/types/executor/SessionRunContext.ts +25 -0
  149. package/src/types/plugin/PluginRuntime.ts +27 -0
  150. package/src/types/runtime/agent/AgentContext.ts +32 -7
  151. package/src/types/session/SessionConfigMutation.ts +78 -0
  152. package/src/types/session/SessionLocalState.ts +9 -0
  153. package/src/types/session/SessionMessage.ts +16 -12
  154. package/src/types/session/SessionMutation.ts +1 -1
  155. package/src/types/session/SessionOptions.ts +21 -0
  156. package/src/types/session/SessionSegment.ts +62 -0
  157. package/tsconfig.tsbuildinfo +1 -1
@@ -17,6 +17,48 @@ import { JsonlSessionHistoryStore } from "../bin/executor/store/history/jsonl/Js
17
17
  import { JsonlSessionHistoryComposer } from "../bin/executor/composer/history/jsonl/JsonlSessionHistoryComposer.js";
18
18
  import { toSessionTimelineEvents } from "../bin/session/browse/Browse.js";
19
19
  import { Agent } from "../bin/index.js";
20
+ import { MockLanguageModelV3 } from "ai/test";
21
+
22
+ function create_stream_text_result(text) {
23
+ return {
24
+ stream: new ReadableStream({
25
+ start(controller) {
26
+ controller.enqueue({ type: "stream-start", warnings: [] });
27
+ controller.enqueue({ type: "text-start", id: "text_1" });
28
+ controller.enqueue({ type: "text-delta", id: "text_1", delta: text });
29
+ controller.enqueue({ type: "text-end", id: "text_1" });
30
+ controller.enqueue({
31
+ type: "finish",
32
+ finishReason: { unified: "stop", raw: "stop" },
33
+ usage: {
34
+ inputTokens: {
35
+ total: 0,
36
+ noCache: 0,
37
+ cacheRead: 0,
38
+ cacheWrite: 0,
39
+ },
40
+ outputTokens: {
41
+ total: 0,
42
+ text: 0,
43
+ reasoning: 0,
44
+ },
45
+ },
46
+ });
47
+ controller.close();
48
+ },
49
+ }),
50
+ };
51
+ }
52
+
53
+ function create_model(model_id, calls) {
54
+ return new MockLanguageModelV3({
55
+ modelId: model_id,
56
+ doStream: async () => {
57
+ calls.push(model_id);
58
+ return create_stream_text_result("done");
59
+ },
60
+ });
61
+ }
20
62
 
21
63
  test("action record is upserted but filtered from LLM history", async () => {
22
64
  const root_path = await fs.mkdtemp(
@@ -107,6 +149,7 @@ test("session.set only persists and publishes model-switching actions when model
107
149
  id: "model_switching_agent",
108
150
  path: agent_path,
109
151
  });
152
+ const model_calls = [];
110
153
  try {
111
154
  const session = await agent.sessions.create({
112
155
  sessionId: "model_switching_session",
@@ -120,37 +163,40 @@ test("session.set only persists and publishes model-switching actions when model
120
163
 
121
164
  await session.set({
122
165
  modelId: "test-model",
123
- model: {
124
- modelId: "test-model-label-v1",
125
- provider: "test",
126
- },
166
+ model: create_model("test-model-label-v1", model_calls),
127
167
  });
128
168
 
129
169
  assert.deepEqual(events, []);
130
170
 
131
171
  await session.set({
132
172
  modelId: "test-model",
133
- model: {
134
- modelId: "test-model-label-v2",
135
- provider: "test",
136
- },
173
+ model: create_model("test-model-label-v2", model_calls),
137
174
  });
138
175
 
139
176
  assert.deepEqual(events, []);
140
177
 
141
178
  await session.set({
142
179
  modelId: "next-test-model",
143
- model: {
144
- modelId: "next-test-model-label",
145
- provider: "test",
146
- },
180
+ model: create_model("next-test-model-label", model_calls),
147
181
  });
182
+
183
+ assert.deepEqual(events, []);
184
+ assert.deepEqual(model_calls, []);
185
+
186
+ const turn = await session.prompt({ query: "apply queued config" });
187
+ const result = await turn.finished;
148
188
  unsubscribe();
149
189
 
190
+ assert.equal(result.success, true);
191
+ assert.deepEqual(model_calls, [
192
+ "next-test-model-label",
193
+ "next-test-model-label",
194
+ ]);
195
+
150
196
  assert.deepEqual(
151
197
  events.map((event) => `${event.title}:${event.status}`),
152
198
  [
153
- "Switching session model from test-model-label-v2 to next-test-model-label:running",
199
+ "Session model switched from test-model-label-v2 to next-test-model-label:running",
154
200
  "Session model switched from test-model-label-v2 to next-test-model-label:completed",
155
201
  ],
156
202
  );
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @file 验证 CityModel 上下文窗口会驱动默认 session compact 预算。
3
+ *
4
+ * 关键点(中文)
5
+ * - CityModel 目录元数据需要在归一化前读取。
6
+ * - 默认 compact 使用模型总窗口的 80%,并随重试次数收紧。
7
+ * - 显式 maxInputTokensApprox 始终优先于模型目录配置。
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import test from "node:test";
12
+
13
+ import {
14
+ JsonlSessionCompactionComposer,
15
+ read_agent_model_context_window,
16
+ } from "../bin/index.js";
17
+ import {
18
+ CITY_MODEL_INVOKER,
19
+ CITY_MODEL_KIND,
20
+ } from "@downcity/type";
21
+
22
+ /** 创建仅用于读取公开目录元数据的 CityModel。 */
23
+ function create_city_model(context_window) {
24
+ return {
25
+ id: "context-model",
26
+ name: "Context Model",
27
+ description: "Context window test model",
28
+ context_window,
29
+ modalities: ["text"],
30
+ tags: [],
31
+ meta: {},
32
+ kind: CITY_MODEL_KIND,
33
+ [CITY_MODEL_INVOKER]: {
34
+ connection: () => ({
35
+ base_url: "https://example.com/v1/ai",
36
+ model_id: "context-model",
37
+ }),
38
+ },
39
+ };
40
+ }
41
+
42
+ /** 执行一次策略并返回传给 history store 的 compact 参数。 */
43
+ async function capture_compact_input(options = {}) {
44
+ let compact_input;
45
+ const composer = new JsonlSessionCompactionComposer(options.composer_options);
46
+ await composer.run({
47
+ historyStore: {
48
+ compact: async (input) => {
49
+ compact_input = input;
50
+ return { compacted: false, reason: "captured" };
51
+ },
52
+ },
53
+ model: {},
54
+ system: [],
55
+ retryCount: options.retry_count ?? 0,
56
+ ...(options.context_window !== undefined
57
+ ? { context_window: options.context_window }
58
+ : {}),
59
+ });
60
+ assert.ok(compact_input);
61
+ return compact_input;
62
+ }
63
+
64
+ test("CityModel exposes its configured context window to Agent", () => {
65
+ assert.equal(
66
+ read_agent_model_context_window(create_city_model(256000)),
67
+ 256000,
68
+ );
69
+ })
70
+
71
+ test("default compact budget uses 80 percent of the model context window", async () => {
72
+ const first_attempt = await capture_compact_input({
73
+ context_window: 256000,
74
+ });
75
+ assert.equal(first_attempt.maxInputTokensApprox, 204800);
76
+
77
+ const retry = await capture_compact_input({
78
+ context_window: 256000,
79
+ retry_count: 1,
80
+ });
81
+ assert.equal(retry.maxInputTokensApprox, 102400);
82
+ })
83
+
84
+ test("explicit compact budget overrides the model context window", async () => {
85
+ const compact_input = await capture_compact_input({
86
+ context_window: 256000,
87
+ composer_options: { maxInputTokensApprox: 64000 },
88
+ });
89
+ assert.equal(compact_input.maxInputTokensApprox, 64000);
90
+ })
91
+
92
+ test("compact keeps the existing 128k fallback without model metadata", async () => {
93
+ const compact_input = await capture_compact_input();
94
+ assert.equal(compact_input.maxInputTokensApprox, 128000);
95
+ })
@@ -0,0 +1,224 @@
1
+ /**
2
+ * @file 验证 Agent 配置在运行中修改后统一于下一模型 turn 生效。
3
+ *
4
+ * 关键点(中文)
5
+ * - 第一轮 provider 请求保持阻塞,用来制造真实的运行中配置修改窗口。
6
+ * - instruction、env 与 plugin registry 修改不能改变已经开始的请求。
7
+ * - 下一模型 turn 统一提交配置,并通过 Session action message 表达生效。
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import fs from "node:fs/promises";
15
+ import { MockLanguageModelV3 } from "ai/test";
16
+ import { Agent } from "../bin/index.js";
17
+ import {
18
+ createAction,
19
+ createPlugin,
20
+ } from "../bin/plugin/core/PluginActionFactory.js";
21
+
22
+ function create_deferred() {
23
+ let resolve;
24
+ const promise = new Promise((inner_resolve) => {
25
+ resolve = inner_resolve;
26
+ });
27
+ return { promise, resolve };
28
+ }
29
+
30
+ function create_stream_text_result(text) {
31
+ return {
32
+ stream: new ReadableStream({
33
+ start(controller) {
34
+ controller.enqueue({ type: "stream-start", warnings: [] });
35
+ controller.enqueue({ type: "text-start", id: "text_1" });
36
+ controller.enqueue({ type: "text-delta", id: "text_1", delta: text });
37
+ controller.enqueue({ type: "text-end", id: "text_1" });
38
+ controller.enqueue({
39
+ type: "finish",
40
+ finishReason: { unified: "stop", raw: "stop" },
41
+ usage: {
42
+ inputTokens: {
43
+ total: 0,
44
+ noCache: 0,
45
+ cacheRead: 0,
46
+ cacheWrite: 0,
47
+ },
48
+ outputTokens: {
49
+ total: 0,
50
+ text: 0,
51
+ reasoning: 0,
52
+ },
53
+ },
54
+ });
55
+ controller.close();
56
+ },
57
+ }),
58
+ };
59
+ }
60
+
61
+ test("running Agent config changes become effective together at the next model turn", async () => {
62
+ const agent_path = await fs.mkdtemp(
63
+ path.join(os.tmpdir(), "downcity-agent-config-turn-boundary-"),
64
+ );
65
+ const first_model_turn_started = create_deferred();
66
+ const release_first_model_turn = create_deferred();
67
+ const provider_prompts = [];
68
+ let provider_turn_count = 0;
69
+
70
+ const model = new MockLanguageModelV3({
71
+ modelId: "config-turn-boundary-model",
72
+ doStream: async (options) => {
73
+ const has_tools = Array.isArray(options.tools) && options.tools.length > 0;
74
+ if (!has_tools) return create_stream_text_result("Session title");
75
+ provider_turn_count += 1;
76
+ provider_prompts.push(JSON.stringify(options.prompt));
77
+ if (provider_turn_count === 1) {
78
+ first_model_turn_started.resolve();
79
+ await release_first_model_turn.promise;
80
+ }
81
+ return create_stream_text_result(`done:${String(provider_turn_count)}`);
82
+ },
83
+ });
84
+ const runtime_plugin = createPlugin({
85
+ name: "runtime-config",
86
+ title: "Runtime Config",
87
+ description: "Provides a system block for turn-boundary tests",
88
+ system: (context) => `plugin-env:${context.env.TURN_ENV || "missing"}`,
89
+ actions: {
90
+ ping: createAction({
91
+ description: "Ping",
92
+ execute: async () => ({ success: true, data: { ok: true } }),
93
+ }),
94
+ },
95
+ });
96
+ const agent = new Agent({
97
+ id: "config_turn_boundary_agent",
98
+ path: agent_path,
99
+ model,
100
+ instruction: ["instruction:old"],
101
+ env: { TURN_ENV: "old" },
102
+ plugins: [runtime_plugin],
103
+ });
104
+
105
+ try {
106
+ const session = await agent.sessions.create({
107
+ sessionId: "config_turn_boundary_session",
108
+ });
109
+ const first_turn = await session.prompt({ query: "first" });
110
+ await first_model_turn_started.promise;
111
+
112
+ agent.setInstruction(["instruction:new"]);
113
+ agent.patchEnv({ TURN_ENV: "new" });
114
+ await agent.plugins.unregister("runtime-config");
115
+
116
+ assert.equal(provider_prompts.length, 1);
117
+ assert.match(provider_prompts[0], /instruction:old/);
118
+ assert.match(provider_prompts[0], /plugin-env:old/);
119
+ assert.doesNotMatch(provider_prompts[0], /instruction:new/);
120
+
121
+ release_first_model_turn.resolve();
122
+ assert.equal((await first_turn.finished).success, true);
123
+
124
+ const second_turn = await session.prompt({ query: "second" });
125
+ assert.equal((await second_turn.finished).success, true);
126
+ assert.equal(provider_prompts.length, 2);
127
+ assert.match(provider_prompts[1], /instruction:new/);
128
+ assert.doesNotMatch(provider_prompts[1], /instruction:old/);
129
+ assert.doesNotMatch(provider_prompts[1], /plugin-env:old/);
130
+
131
+ const messages = await session.messages();
132
+ const completed_actions = messages.items
133
+ .filter((message) => message.type === "action" && message.status === "completed")
134
+ .map((message) => message.title);
135
+ assert.deepEqual(completed_actions, [
136
+ "Agent instruction updated",
137
+ "Agent environment updated",
138
+ "Agent plugin runtime-config unregistered",
139
+ ]);
140
+ } finally {
141
+ release_first_model_turn.resolve();
142
+ await agent.dispose();
143
+ }
144
+ });
145
+
146
+ test("running session model changes keep the current provider and switch the next model turn", async () => {
147
+ const agent_path = await fs.mkdtemp(
148
+ path.join(os.tmpdir(), "downcity-session-model-turn-boundary-"),
149
+ );
150
+ const old_model_started = create_deferred();
151
+ const release_old_model = create_deferred();
152
+ const model_calls = [];
153
+
154
+ const old_model = new MockLanguageModelV3({
155
+ modelId: "old-model",
156
+ doStream: async (options) => {
157
+ const has_tools = Array.isArray(options.tools) && options.tools.length > 0;
158
+ if (!has_tools) return create_stream_text_result("Old title");
159
+ model_calls.push("old-model");
160
+ old_model_started.resolve();
161
+ await release_old_model.promise;
162
+ return create_stream_text_result("old response");
163
+ },
164
+ });
165
+ const new_model = new MockLanguageModelV3({
166
+ modelId: "new-model",
167
+ doStream: async (options) => {
168
+ const has_tools = Array.isArray(options.tools) && options.tools.length > 0;
169
+ if (!has_tools) return create_stream_text_result("New title");
170
+ model_calls.push("new-model");
171
+ return create_stream_text_result("new response");
172
+ },
173
+ });
174
+ const runtime_plugin = createPlugin({
175
+ name: "model-boundary",
176
+ title: "Model Boundary",
177
+ description: "Ensures the main provider request has tools",
178
+ actions: {
179
+ ping: createAction({
180
+ description: "Ping",
181
+ execute: async () => ({ success: true, data: { ok: true } }),
182
+ }),
183
+ },
184
+ });
185
+ const agent = new Agent({
186
+ id: "session_model_turn_boundary_agent",
187
+ path: agent_path,
188
+ model: old_model,
189
+ plugins: [runtime_plugin],
190
+ });
191
+
192
+ try {
193
+ const session = await agent.sessions.create({
194
+ sessionId: "session_model_turn_boundary_session",
195
+ });
196
+ const first_turn = await session.prompt({ query: "first" });
197
+ await old_model_started.promise;
198
+
199
+ await session.set({ model: new_model });
200
+ assert.deepEqual(model_calls, ["old-model"]);
201
+
202
+ release_old_model.resolve();
203
+ assert.equal((await first_turn.finished).success, true);
204
+ assert.deepEqual(model_calls, ["old-model"]);
205
+
206
+ const second_turn = await session.prompt({ query: "second" });
207
+ assert.equal((await second_turn.finished).success, true);
208
+ assert.deepEqual(model_calls, ["old-model", "new-model"]);
209
+
210
+ const messages = await session.messages();
211
+ const model_actions = messages.items.filter(
212
+ (message) =>
213
+ message.type === "action" &&
214
+ message.title === "Session model switched from old-model to new-model",
215
+ );
216
+ assert.deepEqual(
217
+ model_actions.map((message) => message.status),
218
+ ["completed"],
219
+ );
220
+ } finally {
221
+ release_old_model.resolve();
222
+ await agent.dispose();
223
+ }
224
+ });
@@ -118,7 +118,7 @@ test("list_sessions returns persisted title from active session metadata", async
118
118
  "sessions",
119
119
  encodeURIComponent(session.id),
120
120
  "messages",
121
- "messages.jsonl",
121
+ "active.jsonl",
122
122
  ),
123
123
  );
124
124
  assert.equal(meta.historyBytes, history_stat.size);
@@ -158,23 +158,41 @@ test("list_sessions reflects canonical Recorder history changes", async () => {
158
158
  encodeURIComponent(session.id),
159
159
  "messages",
160
160
  );
161
- const messages_path = path.join(messages_dir, "messages.jsonl");
161
+ const messages_path = path.join(messages_dir, "active.jsonl");
162
162
  const meta_path = path.join(messages_dir, "meta.json");
163
163
 
164
164
  try {
165
165
  await session.append_assistant_message({
166
166
  text: "Recorder appended history",
167
167
  });
168
+ await session.recorder.compact_active({
169
+ through_sequence: 1,
170
+ summary: {
171
+ record_type: "summary",
172
+ session_id: session.id,
173
+ summary_id: "summary-through-1",
174
+ through_sequence: 1,
175
+ text: "Initial history was compacted.",
176
+ created_at: Date.now(),
177
+ },
178
+ });
168
179
 
169
180
  const page = await collection.list();
181
+ const info = await session.get_info();
170
182
  const repaired_meta = JSON.parse(await fs.readFile(meta_path, "utf8"));
171
183
  const history_stat = await fs.stat(messages_path);
184
+ const segment_stat = await fs.stat(path.join(
185
+ messages_dir,
186
+ "segments",
187
+ "000000000001-000000000001.jsonl",
188
+ ));
172
189
 
173
190
  assert.equal(page.items[0].messageCount, 2);
191
+ assert.equal(info.messageCount, 2);
174
192
  assert.equal(page.items[0].previewText, "Recorder appended history");
175
193
  assert.equal(repaired_meta.messageCount, 2);
176
194
  assert.equal(repaired_meta.previewText, "Recorder appended history");
177
- assert.equal(repaired_meta.historyBytes, history_stat.size);
195
+ assert.equal(repaired_meta.historyBytes, history_stat.size + segment_stat.size);
178
196
  } finally {
179
197
  await agent.dispose();
180
198
  }
@@ -139,6 +139,91 @@ test("SessionPromptRuntime merges queued prompts at the next step boundary", asy
139
139
  );
140
140
  });
141
141
 
142
+ test("SessionPromptRuntime applies config and steer in queue order before the next model turn", async () => {
143
+ const lifecycle = [];
144
+ const execution_finished = createDeferred();
145
+ let step_merge = null;
146
+
147
+ const runtime = new SessionPromptRuntime({
148
+ sessionId: "test",
149
+ publish: () => {},
150
+ createAndPersistUserMessage: async (input, _turn_id, input_type) => {
151
+ lifecycle.push(`${input_type}:${input.query}`);
152
+ return createUserMessage(input.query, lifecycle.length);
153
+ },
154
+ appendErrorMessage: async () => {},
155
+ executeTurn: async (input) => {
156
+ step_merge = input.onStepMerge;
157
+ await execution_finished.promise;
158
+ return {
159
+ text: "done",
160
+ success: true,
161
+ assistantMessage: createAssistantMessage("done", 1),
162
+ };
163
+ },
164
+ stopTurn: () => false,
165
+ });
166
+
167
+ runtime.enqueue_config({
168
+ mutation_id: "config-1",
169
+ scope: "agent",
170
+ apply: async ({ model_turn_index }) => {
171
+ lifecycle.push(`config:1:${String(model_turn_index)}`);
172
+ },
173
+ });
174
+ const first_turn = await runtime.prompt({ query: "first" });
175
+ const merge = await waitUntil(() => step_merge);
176
+ runtime.enqueue_config({
177
+ mutation_id: "config-2",
178
+ scope: "session",
179
+ apply: async ({ model_turn_index }) => {
180
+ lifecycle.push(`config:2:${String(model_turn_index)}`);
181
+ },
182
+ });
183
+ const second_turn_promise = runtime.prompt({ query: "second" });
184
+
185
+ const messages = await merge();
186
+ const second_turn = await second_turn_promise;
187
+
188
+ assert.equal(second_turn.id, first_turn.id);
189
+ assert.deepEqual(
190
+ messages.map((message) => message.parts[0]?.text),
191
+ ["second"],
192
+ );
193
+ assert.deepEqual(lifecycle, [
194
+ "config:1:1",
195
+ "prompt:first",
196
+ "config:2:2",
197
+ "steer:second",
198
+ ]);
199
+
200
+ execution_finished.resolve();
201
+ await first_turn.finished;
202
+ });
203
+
204
+ test("SessionPromptRuntime keeps config queued without starting a turn", async () => {
205
+ let apply_count = 0;
206
+ const runtime = new SessionPromptRuntime({
207
+ sessionId: "test",
208
+ publish: () => {},
209
+ createAndPersistUserMessage: async (input) => createUserMessage(input.query, 1),
210
+ appendErrorMessage: async () => {},
211
+ executeTurn: async () => ({ text: "done", success: true }),
212
+ stopTurn: () => false,
213
+ });
214
+
215
+ runtime.enqueue_config({
216
+ mutation_id: "config-only",
217
+ scope: "agent",
218
+ apply: async () => {
219
+ apply_count += 1;
220
+ },
221
+ });
222
+
223
+ assert.equal(runtime.isActive(), false);
224
+ assert.equal(apply_count, 0);
225
+ });
226
+
142
227
  test("SessionPromptRuntime moves unmerged prompts into the next turn", async () => {
143
228
  const events = [];
144
229
  const finishQueue = [];