@downcity/agent 1.1.267 → 1.1.270
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.
- package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
- package/bin/executor/core-engine/CoreEngineRunner.js +1 -2
- package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
- package/bin/executor/services/ExecutorRecoveryPolicy.d.ts +0 -1
- package/bin/executor/services/ExecutorRecoveryPolicy.d.ts.map +1 -1
- package/bin/executor/services/ExecutorRecoveryPolicy.js +2 -17
- package/bin/executor/services/ExecutorRecoveryPolicy.js.map +1 -1
- package/bin/executor/types/SessionRecords.d.ts +5 -7
- package/bin/executor/types/SessionRecords.d.ts.map +1 -1
- package/bin/executor/types/SessionRecords.js +3 -4
- package/bin/executor/types/SessionRecords.js.map +1 -1
- package/bin/index.d.ts +1 -1
- package/bin/index.d.ts.map +1 -1
- package/bin/index.js.map +1 -1
- package/bin/session/SessionMessages.d.ts.map +1 -1
- package/bin/session/SessionMessages.js +14 -1
- package/bin/session/SessionMessages.js.map +1 -1
- package/bin/session/SessionSystem.js +6 -6
- package/bin/session/SessionSystem.js.map +1 -1
- package/bin/session/SessionTitle.d.ts.map +1 -1
- package/bin/session/SessionTitle.js +0 -6
- package/bin/session/SessionTitle.js.map +1 -1
- package/bin/session/SessionTurn.d.ts.map +1 -1
- package/bin/session/SessionTurn.js +0 -25
- package/bin/session/SessionTurn.js.map +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.d.ts +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.js +153 -12
- package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
- package/bin/session/messages/SessionJsonValue.d.ts +3 -1
- package/bin/session/messages/SessionJsonValue.d.ts.map +1 -1
- package/bin/session/messages/SessionJsonValue.js +12 -0
- package/bin/session/messages/SessionJsonValue.js.map +1 -1
- package/bin/session/messages/SessionMessageCodec.d.ts.map +1 -1
- package/bin/session/messages/SessionMessageCodec.js +226 -19
- package/bin/session/messages/SessionMessageCodec.js.map +1 -1
- package/bin/types/sdk/AgentSessionTurn.d.ts +2 -1
- package/bin/types/sdk/AgentSessionTurn.d.ts.map +1 -1
- package/bin/types/session/SessionMessage.d.ts +87 -3
- package/bin/types/session/SessionMessage.d.ts.map +1 -1
- package/package.json +3 -3
- package/scripts/agent-session-maintenance.test.mjs +7 -1
- package/scripts/executor-failure-result.test.mjs +81 -0
- package/scripts/openai-responses-continuation.test.mjs +35 -5
- package/scripts/session-messages.test.mjs +287 -1
- package/scripts/session-shell-approval.test.mjs +3 -3
- package/scripts/session-system-blocks.test.mjs +14 -1
- package/scripts/session-turn-failure.test.mjs +101 -0
- package/src/executor/core-engine/CoreEngineRunner.ts +1 -5
- package/src/executor/services/ExecutorRecoveryPolicy.ts +2 -23
- package/src/executor/types/SessionRecords.ts +5 -7
- package/src/index.ts +11 -0
- package/src/session/SessionMessages.ts +14 -1
- package/src/session/SessionSystem.ts +6 -6
- package/src/session/SessionTitle.ts +0 -6
- package/src/session/SessionTurn.ts +0 -29
- package/src/session/messages/SessionAssistantMessageWriter.ts +170 -12
- package/src/session/messages/SessionJsonValue.ts +12 -1
- package/src/session/messages/SessionMessageCodec.ts +226 -18
- package/src/types/sdk/AgentSessionTurn.ts +2 -1
- package/src/types/session/SessionMessage.ts +96 -3
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@downcity/agent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.270",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Downcity Agent 运行时 — 单 Agent 执行壳与本机 RPC 能力",
|
|
6
6
|
"main": "./bin/index.js",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@downcity/shell": "^0.1.
|
|
16
|
-
"@downcity/type": "0.1.
|
|
15
|
+
"@downcity/shell": "^0.1.125",
|
|
16
|
+
"@downcity/type": "0.1.155",
|
|
17
17
|
"ai": "^6.0.193",
|
|
18
18
|
"commander": "^15.0.0",
|
|
19
19
|
"dotenv": "^17.4.2",
|
|
@@ -28,6 +28,7 @@ test("AgentContext sessions 负责清空消息和删除 Session 数据", async (
|
|
|
28
28
|
const root_path = create_project_root();
|
|
29
29
|
const agent = new Agent({ id: "agent_test", path: root_path });
|
|
30
30
|
try {
|
|
31
|
+
await agent.ready();
|
|
31
32
|
assert.equal(agent.getContext().sessions, agent.sessions);
|
|
32
33
|
const session_id = "session_test";
|
|
33
34
|
await agent.sessions.create({ sessionId: session_id });
|
|
@@ -47,6 +48,11 @@ test("AgentContext sessions 负责清空消息和删除 Session 数据", async (
|
|
|
47
48
|
assert.equal(fs.existsSync(session_path), false);
|
|
48
49
|
} finally {
|
|
49
50
|
await agent.dispose();
|
|
50
|
-
fs.rmSync(root_path, {
|
|
51
|
+
fs.rmSync(root_path, {
|
|
52
|
+
recursive: true,
|
|
53
|
+
force: true,
|
|
54
|
+
maxRetries: 5,
|
|
55
|
+
retryDelay: 50,
|
|
56
|
+
});
|
|
51
57
|
}
|
|
52
58
|
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file 验证执行器失败结果不会伪造 Assistant 正文。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { MockLanguageModelV3 } from "ai/test";
|
|
8
|
+
|
|
9
|
+
import { CoreEngineRunner } from "../bin/executor/core-engine/CoreEngineRunner.js";
|
|
10
|
+
import { ExecutorRecoveryPolicy } from "../bin/executor/services/ExecutorRecoveryPolicy.js";
|
|
11
|
+
|
|
12
|
+
function create_run_context() {
|
|
13
|
+
return {
|
|
14
|
+
sessionId: "executor-failure-test",
|
|
15
|
+
injectedUserMessages: [],
|
|
16
|
+
deferredPersistedUserMessages: [],
|
|
17
|
+
pendingAssistantFileParts: [],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("CoreEngine Provider 失败时只返回结构化错误", async () => {
|
|
22
|
+
const model = new MockLanguageModelV3({
|
|
23
|
+
modelId: "failing-model",
|
|
24
|
+
doStream: async () => {
|
|
25
|
+
throw new Error("quota exceeded");
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const runner = new CoreEngineRunner({
|
|
29
|
+
session_id: "executor-failure-test",
|
|
30
|
+
logger: { log: async () => {} },
|
|
31
|
+
should_compact_on_error: () => false,
|
|
32
|
+
});
|
|
33
|
+
const messages = [{
|
|
34
|
+
id: "user-1",
|
|
35
|
+
role: "user",
|
|
36
|
+
metadata: {
|
|
37
|
+
v: 1,
|
|
38
|
+
ts: 1,
|
|
39
|
+
sessionId: "executor-failure-test",
|
|
40
|
+
source: "ingress",
|
|
41
|
+
kind: "normal",
|
|
42
|
+
},
|
|
43
|
+
parts: [{ type: "text", text: "hello" }],
|
|
44
|
+
}];
|
|
45
|
+
|
|
46
|
+
const result = await runner.run({
|
|
47
|
+
execute_input: { query: "hello", system: [], messages, tools: {} },
|
|
48
|
+
model,
|
|
49
|
+
run_context: create_run_context(),
|
|
50
|
+
resolve_step_inputs: async () => ({ model, system: [], tools: {} }),
|
|
51
|
+
reload_history: async () => messages,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
assert.equal(result.success, false);
|
|
55
|
+
assert.match(result.error, /quota exceeded/);
|
|
56
|
+
assert.equal(result.assistantMessage, undefined);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("恢复策略捕获普通异常后只返回结构化错误", async () => {
|
|
60
|
+
const policy = new ExecutorRecoveryPolicy({
|
|
61
|
+
session_id: "executor-failure-test",
|
|
62
|
+
logger: { log: async () => {} },
|
|
63
|
+
should_compact: () => false,
|
|
64
|
+
});
|
|
65
|
+
const run_context = create_run_context();
|
|
66
|
+
const result = await policy.run_with_retry({
|
|
67
|
+
query: "hello",
|
|
68
|
+
model: {},
|
|
69
|
+
run_context,
|
|
70
|
+
prepare_execute_input: async () => {
|
|
71
|
+
throw new Error("configuration failed");
|
|
72
|
+
},
|
|
73
|
+
execute_prepared_run: async () => {
|
|
74
|
+
throw new Error("unexpected execution");
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
assert.equal(result.success, false);
|
|
79
|
+
assert.match(result.error, /configuration failed/);
|
|
80
|
+
assert.equal(result.assistantMessage, undefined);
|
|
81
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file 验证
|
|
2
|
+
* @file 验证 Session 使用完整工具事务续接 OpenAI Responses。
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import test from "node:test";
|
|
@@ -7,7 +7,8 @@ import assert from "node:assert/strict";
|
|
|
7
7
|
import { createOpenAI } from "@ai-sdk/openai";
|
|
8
8
|
import { convertToModelMessages } from "ai";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/** 捕获 AI SDK 在指定 store 策略下生成的 Responses 请求体。 */
|
|
11
|
+
async function capture_request_body(store) {
|
|
11
12
|
let request_body;
|
|
12
13
|
const openai = createOpenAI({
|
|
13
14
|
apiKey: "test-api-key",
|
|
@@ -30,11 +31,25 @@ test("OpenAI Responses 默认 store 策略使用 item_reference continuation", a
|
|
|
30
31
|
}]);
|
|
31
32
|
|
|
32
33
|
await assert.rejects(
|
|
33
|
-
openai.responses("gpt-5").doGenerate({
|
|
34
|
+
openai.responses("gpt-5").doGenerate({
|
|
35
|
+
prompt: messages,
|
|
36
|
+
providerOptions: { openai: { store } },
|
|
37
|
+
}),
|
|
34
38
|
/request captured/,
|
|
35
39
|
);
|
|
40
|
+
return request_body;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("Responses store=false 发送完整 function_call", async () => {
|
|
44
|
+
const request_body = await capture_request_body(false);
|
|
36
45
|
assert.deepEqual(request_body.input, [
|
|
37
|
-
{
|
|
46
|
+
{
|
|
47
|
+
type: "function_call",
|
|
48
|
+
call_id: "call_1",
|
|
49
|
+
name: "lookup",
|
|
50
|
+
arguments: "{\"q\":\"x\"}",
|
|
51
|
+
id: "fc_1",
|
|
52
|
+
},
|
|
38
53
|
{
|
|
39
54
|
type: "function_call_output",
|
|
40
55
|
call_id: "call_1",
|
|
@@ -42,7 +57,22 @@ test("OpenAI Responses 默认 store 策略使用 item_reference continuation", a
|
|
|
42
57
|
},
|
|
43
58
|
]);
|
|
44
59
|
assert.equal(
|
|
45
|
-
request_body.input.some((item) => item.type === "
|
|
60
|
+
request_body.input.some((item) => item.type === "item_reference"),
|
|
46
61
|
false,
|
|
47
62
|
);
|
|
48
63
|
});
|
|
64
|
+
|
|
65
|
+
test("Responses store=true 使用已存储 item_reference", async () => {
|
|
66
|
+
const request_body = await capture_request_body(true);
|
|
67
|
+
assert.deepEqual(request_body.input, [
|
|
68
|
+
{
|
|
69
|
+
type: "item_reference",
|
|
70
|
+
id: "fc_1",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
type: "function_call_output",
|
|
74
|
+
call_id: "call_1",
|
|
75
|
+
output: "ok",
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
@@ -14,6 +14,7 @@ import { SessionApprovalBroker } from "../bin/session/approval/SessionApprovalBr
|
|
|
14
14
|
import { compose_session_compaction } from "../bin/session/messages/SessionMessageCompaction.js";
|
|
15
15
|
import {
|
|
16
16
|
from_ui_assistant_parts,
|
|
17
|
+
from_ui_user_parts,
|
|
17
18
|
to_executor_history,
|
|
18
19
|
to_executor_ui_message,
|
|
19
20
|
} from "../bin/session/messages/SessionMessageCodec.js";
|
|
@@ -281,6 +282,126 @@ test("Streaming Tool Provider metadata 在输入和输出状态间完整保留",
|
|
|
281
282
|
assert.equal(tool_part.state, "completed");
|
|
282
283
|
});
|
|
283
284
|
|
|
285
|
+
test("AI SDK 流式 metadata、source、data、step 和审批状态完整持久化", async () => {
|
|
286
|
+
const { recorder, file_path } = await create_recorder("ui-chunk-semantics-test");
|
|
287
|
+
const writer = await recorder.open_assistant_message({
|
|
288
|
+
turn_id: "turn-ui-chunks",
|
|
289
|
+
segment_index: 1,
|
|
290
|
+
});
|
|
291
|
+
await writer.apply_chunk({
|
|
292
|
+
type: "text-start",
|
|
293
|
+
id: "text_1",
|
|
294
|
+
providerMetadata: { openai: { itemId: "text_start" } },
|
|
295
|
+
});
|
|
296
|
+
await writer.apply_chunk({ type: "text-delta", id: "text_1", delta: "hello" });
|
|
297
|
+
await writer.apply_chunk({
|
|
298
|
+
type: "text-end",
|
|
299
|
+
id: "text_1",
|
|
300
|
+
providerMetadata: { openai: { itemId: "text_end" } },
|
|
301
|
+
});
|
|
302
|
+
await writer.apply_chunk({ type: "reasoning-start", id: "reasoning_1" });
|
|
303
|
+
await writer.apply_chunk({
|
|
304
|
+
type: "reasoning-delta",
|
|
305
|
+
id: "reasoning_1",
|
|
306
|
+
delta: "think",
|
|
307
|
+
providerMetadata: { openai: { itemId: "reasoning_delta" } },
|
|
308
|
+
});
|
|
309
|
+
await writer.apply_chunk({ type: "reasoning-end", id: "reasoning_1" });
|
|
310
|
+
await writer.apply_chunk({
|
|
311
|
+
type: "source-url",
|
|
312
|
+
sourceId: "source_1",
|
|
313
|
+
url: "https://example.com/old",
|
|
314
|
+
});
|
|
315
|
+
await writer.apply_chunk({
|
|
316
|
+
type: "source-url",
|
|
317
|
+
sourceId: "source_1",
|
|
318
|
+
url: "https://example.com/new",
|
|
319
|
+
title: "Updated source",
|
|
320
|
+
providerMetadata: { openai: { itemId: "source_item" } },
|
|
321
|
+
});
|
|
322
|
+
await writer.apply_chunk({
|
|
323
|
+
type: "data-progress",
|
|
324
|
+
id: "progress_1",
|
|
325
|
+
data: { percent: 10 },
|
|
326
|
+
});
|
|
327
|
+
await writer.apply_chunk({
|
|
328
|
+
type: "data-progress",
|
|
329
|
+
id: "progress_1",
|
|
330
|
+
data: { percent: 90 },
|
|
331
|
+
});
|
|
332
|
+
await writer.apply_chunk({
|
|
333
|
+
type: "data-progress",
|
|
334
|
+
id: "transient_1",
|
|
335
|
+
data: { percent: 100 },
|
|
336
|
+
transient: true,
|
|
337
|
+
});
|
|
338
|
+
await writer.apply_chunk({
|
|
339
|
+
type: "tool-input-start",
|
|
340
|
+
toolCallId: "call_approval",
|
|
341
|
+
toolName: "lookup",
|
|
342
|
+
title: "Lookup",
|
|
343
|
+
dynamic: true,
|
|
344
|
+
providerMetadata: { openai: { itemId: "fc_approval" } },
|
|
345
|
+
});
|
|
346
|
+
await writer.apply_chunk({
|
|
347
|
+
type: "tool-input-available",
|
|
348
|
+
toolCallId: "call_approval",
|
|
349
|
+
toolName: "lookup",
|
|
350
|
+
input: { q: "downcity" },
|
|
351
|
+
toolMetadata: { category: "search" },
|
|
352
|
+
});
|
|
353
|
+
await writer.apply_chunk({
|
|
354
|
+
type: "tool-approval-request",
|
|
355
|
+
approvalId: "approval_stream_1",
|
|
356
|
+
toolCallId: "call_approval",
|
|
357
|
+
});
|
|
358
|
+
await writer.apply_chunk({
|
|
359
|
+
type: "tool-output-denied",
|
|
360
|
+
toolCallId: "call_approval",
|
|
361
|
+
});
|
|
362
|
+
await writer.apply_chunk({
|
|
363
|
+
type: "file",
|
|
364
|
+
mediaType: "image/png",
|
|
365
|
+
url: "https://example.com/output.png",
|
|
366
|
+
providerMetadata: { openai: { fileId: "output_file" } },
|
|
367
|
+
});
|
|
368
|
+
await writer.apply_chunk({ type: "start-step" });
|
|
369
|
+
await writer.complete();
|
|
370
|
+
|
|
371
|
+
const assistant = (await read_jsonl(file_path))[0];
|
|
372
|
+
assert.deepEqual(assistant.parts.map((part) => part.type), [
|
|
373
|
+
"text",
|
|
374
|
+
"reasoning",
|
|
375
|
+
"source",
|
|
376
|
+
"data",
|
|
377
|
+
"tool",
|
|
378
|
+
"file",
|
|
379
|
+
"step-start",
|
|
380
|
+
]);
|
|
381
|
+
assert.deepEqual(assistant.parts[0].provider_metadata, {
|
|
382
|
+
openai: { itemId: "text_end" },
|
|
383
|
+
});
|
|
384
|
+
assert.deepEqual(assistant.parts[1].provider_metadata, {
|
|
385
|
+
openai: { itemId: "reasoning_delta" },
|
|
386
|
+
});
|
|
387
|
+
assert.equal(assistant.parts[2].url, "https://example.com/new");
|
|
388
|
+
assert.equal(assistant.parts[2].title, "Updated source");
|
|
389
|
+
assert.deepEqual(assistant.parts[3].data, { percent: 90 });
|
|
390
|
+
const tool_part = assistant.parts[4];
|
|
391
|
+
assert.equal(tool_part.state, "failed");
|
|
392
|
+
assert.equal(tool_part.title, "Lookup");
|
|
393
|
+
assert.equal(tool_part.dynamic, true);
|
|
394
|
+
assert.deepEqual(tool_part.tool_metadata, { category: "search" });
|
|
395
|
+
assert.deepEqual(tool_part.approval, {
|
|
396
|
+
approval_id: "approval_stream_1",
|
|
397
|
+
approved: false,
|
|
398
|
+
});
|
|
399
|
+
assert.equal(
|
|
400
|
+
assistant.parts.some((part) => part.type === "data" && part.data_id === "transient_1"),
|
|
401
|
+
false,
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
284
405
|
test("UI Tool Provider metadata 经 Session roundtrip 后恢复为 ModelMessage providerOptions", async () => {
|
|
285
406
|
const parts = from_ui_assistant_parts([{
|
|
286
407
|
type: "dynamic-tool",
|
|
@@ -388,6 +509,171 @@ test("UI Tool Provider metadata 经 Session roundtrip 后恢复为 ModelMessage
|
|
|
388
509
|
});
|
|
389
510
|
});
|
|
390
511
|
|
|
512
|
+
test("SessionMessage 无损恢复 AI SDK UIMessage 的完整 Part 语义", () => {
|
|
513
|
+
const user_parts = [
|
|
514
|
+
{
|
|
515
|
+
type: "text",
|
|
516
|
+
text: "分析附件",
|
|
517
|
+
state: "done",
|
|
518
|
+
providerMetadata: { openai: { itemId: "user_text_1" } },
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
type: "file",
|
|
522
|
+
mediaType: "image/png",
|
|
523
|
+
url: "https://example.com/input.png",
|
|
524
|
+
filename: "input.png",
|
|
525
|
+
providerMetadata: { openai: { fileId: "file_1" } },
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
type: "data-preferences",
|
|
529
|
+
id: "preferences_1",
|
|
530
|
+
data: { locale: "zh-CN" },
|
|
531
|
+
},
|
|
532
|
+
];
|
|
533
|
+
const restored_user = to_executor_ui_message({
|
|
534
|
+
message_id: "user_roundtrip",
|
|
535
|
+
session_id: "session_roundtrip",
|
|
536
|
+
turn_id: "turn_roundtrip",
|
|
537
|
+
sequence: 1,
|
|
538
|
+
revision: 1,
|
|
539
|
+
visibility: "visible",
|
|
540
|
+
created_at: 1,
|
|
541
|
+
updated_at: 1,
|
|
542
|
+
type: "user",
|
|
543
|
+
input_type: "prompt",
|
|
544
|
+
parts: from_ui_user_parts(user_parts),
|
|
545
|
+
});
|
|
546
|
+
assert.deepEqual(restored_user.parts, user_parts);
|
|
547
|
+
|
|
548
|
+
const assistant_parts = [
|
|
549
|
+
{
|
|
550
|
+
type: "text",
|
|
551
|
+
text: "正在处理",
|
|
552
|
+
state: "streaming",
|
|
553
|
+
providerMetadata: { openai: { itemId: "msg_1" } },
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
type: "reasoning",
|
|
557
|
+
text: "先读取来源",
|
|
558
|
+
state: "done",
|
|
559
|
+
providerMetadata: { openai: { itemId: "reasoning_1" } },
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
type: "file",
|
|
563
|
+
mediaType: "application/pdf",
|
|
564
|
+
url: "https://example.com/report.pdf",
|
|
565
|
+
filename: "report.pdf",
|
|
566
|
+
providerMetadata: { openai: { fileId: "file_2" } },
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
type: "source-url",
|
|
570
|
+
sourceId: "source_url_1",
|
|
571
|
+
url: "https://example.com/source",
|
|
572
|
+
title: "Example source",
|
|
573
|
+
providerMetadata: { openai: { itemId: "source_1" } },
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
type: "source-document",
|
|
577
|
+
sourceId: "source_document_1",
|
|
578
|
+
mediaType: "application/pdf",
|
|
579
|
+
title: "Source document",
|
|
580
|
+
filename: "source.pdf",
|
|
581
|
+
providerMetadata: { openai: { itemId: "source_2" } },
|
|
582
|
+
},
|
|
583
|
+
{ type: "step-start" },
|
|
584
|
+
{
|
|
585
|
+
type: "data-progress",
|
|
586
|
+
id: "progress_1",
|
|
587
|
+
data: { percent: 80 },
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
type: "dynamic-tool",
|
|
591
|
+
toolName: "lookup",
|
|
592
|
+
toolCallId: "call_streaming",
|
|
593
|
+
state: "input-streaming",
|
|
594
|
+
title: "Lookup",
|
|
595
|
+
toolMetadata: { category: "search" },
|
|
596
|
+
providerExecuted: false,
|
|
597
|
+
callProviderMetadata: { openai: { itemId: "fc_streaming" } },
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
type: "tool-fetch",
|
|
601
|
+
toolCallId: "call_ready",
|
|
602
|
+
state: "input-available",
|
|
603
|
+
input: { url: "https://example.com" },
|
|
604
|
+
title: "Fetch",
|
|
605
|
+
toolMetadata: { category: "network" },
|
|
606
|
+
providerExecuted: true,
|
|
607
|
+
callProviderMetadata: { openai: { itemId: "fc_ready" } },
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
type: "dynamic-tool",
|
|
611
|
+
toolName: "shell_exec",
|
|
612
|
+
toolCallId: "call_approval",
|
|
613
|
+
state: "approval-requested",
|
|
614
|
+
input: { cmd: "pwd" },
|
|
615
|
+
approval: { id: "approval_1" },
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
type: "tool-shell_exec",
|
|
619
|
+
toolCallId: "call_responded",
|
|
620
|
+
state: "approval-responded",
|
|
621
|
+
input: { cmd: "ls" },
|
|
622
|
+
approval: { id: "approval_2", approved: true, reason: "Allowed" },
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
type: "dynamic-tool",
|
|
626
|
+
toolName: "lookup",
|
|
627
|
+
toolCallId: "call_completed",
|
|
628
|
+
state: "output-available",
|
|
629
|
+
input: { q: "downcity" },
|
|
630
|
+
output: { count: 1 },
|
|
631
|
+
preliminary: true,
|
|
632
|
+
approval: { id: "approval_3", approved: true, reason: "Allowed" },
|
|
633
|
+
callProviderMetadata: { openai: { itemId: "fc_completed" } },
|
|
634
|
+
resultProviderMetadata: { openai: { itemId: "result_completed" } },
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
type: "dynamic-tool",
|
|
638
|
+
toolName: "parse",
|
|
639
|
+
toolCallId: "call_error",
|
|
640
|
+
state: "output-error",
|
|
641
|
+
input: undefined,
|
|
642
|
+
rawInput: "{invalid",
|
|
643
|
+
errorText: "Invalid input",
|
|
644
|
+
approval: { id: "approval_4", approved: true },
|
|
645
|
+
resultProviderMetadata: { openai: { itemId: "result_error" } },
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
type: "dynamic-tool",
|
|
649
|
+
toolName: "delete",
|
|
650
|
+
toolCallId: "call_denied",
|
|
651
|
+
state: "output-denied",
|
|
652
|
+
input: { path: "/tmp/a" },
|
|
653
|
+
approval: { id: "approval_5", approved: false, reason: "Denied" },
|
|
654
|
+
},
|
|
655
|
+
];
|
|
656
|
+
const canonical_parts = from_ui_assistant_parts(assistant_parts);
|
|
657
|
+
const restored_assistant = to_executor_ui_message({
|
|
658
|
+
message_id: "assistant_roundtrip",
|
|
659
|
+
session_id: "session_roundtrip",
|
|
660
|
+
turn_id: "turn_roundtrip",
|
|
661
|
+
sequence: 2,
|
|
662
|
+
revision: 1,
|
|
663
|
+
visibility: "visible",
|
|
664
|
+
created_at: 2,
|
|
665
|
+
updated_at: 2,
|
|
666
|
+
type: "assistant",
|
|
667
|
+
kind: "normal",
|
|
668
|
+
segment_index: 1,
|
|
669
|
+
status: "streaming",
|
|
670
|
+
parts: canonical_parts,
|
|
671
|
+
});
|
|
672
|
+
assert.deepEqual(restored_assistant.parts, assistant_parts);
|
|
673
|
+
assert.equal(canonical_parts.find((part) => part.tool_call_id === "call_ready").dynamic, false);
|
|
674
|
+
assert.equal(canonical_parts.find((part) => part.tool_call_id === "call_completed").dynamic, true);
|
|
675
|
+
});
|
|
676
|
+
|
|
391
677
|
test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
|
|
392
678
|
const { recorder, events } = await create_recorder("tool-ready-barrier-test");
|
|
393
679
|
const approval_broker = new SessionApprovalBroker({
|
|
@@ -430,7 +716,7 @@ test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
|
|
|
430
716
|
const tool = recorder.get_message(writer.message_id).parts[0];
|
|
431
717
|
assert.equal(tool.state, "approval-required");
|
|
432
718
|
assert.equal(tool.approval.approval_id, approval_handle.approval_id);
|
|
433
|
-
assert.equal(tool.approval.command, approval_input.command);
|
|
719
|
+
assert.equal(tool.approval.request.command, approval_input.command);
|
|
434
720
|
assert.equal(tool.input.cmd, "ls -la /Users/example/Desktop");
|
|
435
721
|
await approval_broker.resolve({
|
|
436
722
|
approval_id: approval_handle.approval_id,
|
|
@@ -154,9 +154,9 @@ test("unrestricted Shell 审批保留当前 Turn 并等待用户决定", async (
|
|
|
154
154
|
.flatMap((message) => message.type === "assistant" ? message.parts : [])
|
|
155
155
|
.find((part) => part.type === "tool" && part.tool_call_id === "call_unrestricted");
|
|
156
156
|
assert.ok(approval_snapshot, JSON.stringify(messages.items));
|
|
157
|
-
assert.equal(approval_snapshot.session_id, session.id);
|
|
158
|
-
assert.equal(approval_snapshot.turn_id, turn.id);
|
|
159
|
-
assert.equal(approval_snapshot.tool_call_id, "call_unrestricted");
|
|
157
|
+
assert.equal(approval_snapshot.request.session_id, session.id);
|
|
158
|
+
assert.equal(approval_snapshot.request.turn_id, turn.id);
|
|
159
|
+
assert.equal(approval_snapshot.request.tool_call_id, "call_unrestricted");
|
|
160
160
|
assert.deepEqual(await approval_result, {
|
|
161
161
|
success: true,
|
|
162
162
|
approval_id: approval_snapshot.approval_id,
|
|
@@ -69,5 +69,18 @@ test("session system blocks are ordered as instruction, core, plugin, session",
|
|
|
69
69
|
);
|
|
70
70
|
assert.equal(blocks[0].content, "使用中文回复。");
|
|
71
71
|
assert.match(blocks[1].content, /plugin_call/);
|
|
72
|
-
assert.match(blocks[3].content,
|
|
72
|
+
assert.match(blocks[3].content, /^Current session context:/);
|
|
73
|
+
assert.match(
|
|
74
|
+
blocks[3].content,
|
|
75
|
+
/You are serving agent "agent-test" in session "session-test"\./,
|
|
76
|
+
);
|
|
77
|
+
assert.match(
|
|
78
|
+
blocks[3].content,
|
|
79
|
+
/The current project root is "\/tmp\/downcity-project"\./,
|
|
80
|
+
);
|
|
81
|
+
assert.match(
|
|
82
|
+
blocks[3].content,
|
|
83
|
+
/This session was created at 2026-07-09T08:00:00\.000Z, with Asia\/Shanghai as its reference timezone\./,
|
|
84
|
+
);
|
|
85
|
+
assert.doesNotMatch(blocks[3].content, /[\u3400-\u9fff]/u);
|
|
73
86
|
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file 验证 Turn 失败只写入一次结构化错误,并保留已经生成的 Assistant 内容。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
|
|
11
|
+
import { SessionApprovalBroker } from "../bin/session/approval/SessionApprovalBroker.js";
|
|
12
|
+
import { JsonlSessionMessageStore } from "../bin/session/messages/JsonlSessionMessageStore.js";
|
|
13
|
+
import { SessionMessages } from "../bin/session/SessionMessages.js";
|
|
14
|
+
import { SessionEventHub } from "../bin/session/runtime/SessionEventHub.js";
|
|
15
|
+
import { SessionTurn } from "../bin/session/SessionTurn.js";
|
|
16
|
+
|
|
17
|
+
async function create_turn_harness(execute_run) {
|
|
18
|
+
const session_id = "session-turn-failure-test";
|
|
19
|
+
const root_path = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-turn-failure-"));
|
|
20
|
+
const messages = new SessionMessages({
|
|
21
|
+
session_id,
|
|
22
|
+
store: new JsonlSessionMessageStore({
|
|
23
|
+
session_id,
|
|
24
|
+
file_path: path.join(root_path, "active.jsonl"),
|
|
25
|
+
}),
|
|
26
|
+
publish: () => {},
|
|
27
|
+
});
|
|
28
|
+
await messages.initialize();
|
|
29
|
+
|
|
30
|
+
const turn = new SessionTurn({
|
|
31
|
+
session_id,
|
|
32
|
+
project_root: root_path,
|
|
33
|
+
executor: {
|
|
34
|
+
run: async ({ runContext }) => await execute_run(runContext),
|
|
35
|
+
stop: () => false,
|
|
36
|
+
compact_history: async () => ({ compacted: false, reason: "nothing_to_compact" }),
|
|
37
|
+
},
|
|
38
|
+
state: {
|
|
39
|
+
ensure_runnable: async () => {},
|
|
40
|
+
ensure_title_from_history: async () => {},
|
|
41
|
+
touch_metadata: async () => {},
|
|
42
|
+
},
|
|
43
|
+
messages,
|
|
44
|
+
events: new SessionEventHub(),
|
|
45
|
+
approvals: new SessionApprovalBroker({ session_id, messages }),
|
|
46
|
+
apply_command: async () => {},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return { messages, turn };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test("Provider 在输出前失败时只持久化 Error Message", async () => {
|
|
53
|
+
const { messages, turn } = await create_turn_harness(async () => ({
|
|
54
|
+
success: false,
|
|
55
|
+
error: "quota exceeded",
|
|
56
|
+
deferredPersistedUserMessages: [],
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const handle = await turn.prompt({ query: "hello" });
|
|
60
|
+
const result = await handle.finished;
|
|
61
|
+
const page = await messages.list_messages();
|
|
62
|
+
|
|
63
|
+
assert.equal(result.success, false);
|
|
64
|
+
assert.equal(result.error, "quota exceeded");
|
|
65
|
+
assert.equal(result.assistantMessage, undefined);
|
|
66
|
+
assert.deepEqual(page.items.map((message) => message.type), ["user", "error"]);
|
|
67
|
+
assert.equal(page.items[1].code, "turn_execution_failed");
|
|
68
|
+
assert.equal(page.items[1].message, "quota exceeded");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("Provider 在部分输出后失败时保留 failed Assistant 并追加 Error Message", async () => {
|
|
72
|
+
const { messages, turn } = await create_turn_harness(async (run_context) => {
|
|
73
|
+
await run_context.onUiMessageChunkCallback({ type: "text-start", id: "text-1" });
|
|
74
|
+
await run_context.onUiMessageChunkCallback({
|
|
75
|
+
type: "text-delta",
|
|
76
|
+
id: "text-1",
|
|
77
|
+
delta: "partial response",
|
|
78
|
+
});
|
|
79
|
+
await run_context.onUiMessageChunkCallback({ type: "text-end", id: "text-1" });
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
error: "stream interrupted",
|
|
83
|
+
deferredPersistedUserMessages: [],
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const handle = await turn.prompt({ query: "hello" });
|
|
88
|
+
const result = await handle.finished;
|
|
89
|
+
const page = await messages.list_messages();
|
|
90
|
+
|
|
91
|
+
assert.equal(result.success, false);
|
|
92
|
+
assert.equal(result.assistantMessage, undefined);
|
|
93
|
+
assert.deepEqual(page.items.map((message) => message.type), [
|
|
94
|
+
"user",
|
|
95
|
+
"assistant",
|
|
96
|
+
"error",
|
|
97
|
+
]);
|
|
98
|
+
assert.equal(page.items[1].status, "failed");
|
|
99
|
+
assert.equal(page.items[1].parts[0].text, "partial response");
|
|
100
|
+
assert.equal(page.items[2].message, "stream interrupted");
|
|
101
|
+
});
|
|
@@ -601,10 +601,6 @@ export class CoreEngineRunner {
|
|
|
601
601
|
return {
|
|
602
602
|
success: false,
|
|
603
603
|
error: error_text,
|
|
604
|
-
assistantMessage: build_fallback_assistant_message(
|
|
605
|
-
session_id,
|
|
606
|
-
`Execution failed: ${error_text}`,
|
|
607
|
-
),
|
|
608
604
|
...(compact_required ? { compact_required: true } : {}),
|
|
609
605
|
deferredPersistedUserMessages: [
|
|
610
606
|
...input.run_context.deferredPersistedUserMessages,
|
|
@@ -635,7 +631,7 @@ function build_internal_user_message(input: {
|
|
|
635
631
|
};
|
|
636
632
|
}
|
|
637
633
|
|
|
638
|
-
/**
|
|
634
|
+
/** 构造成功执行但缺少最终消息时使用的临时 Assistant Message。 */
|
|
639
635
|
function build_fallback_assistant_message(
|
|
640
636
|
session_id: string,
|
|
641
637
|
text: string,
|