@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.
- package/bin/agent/Agent.d.ts.map +1 -1
- package/bin/agent/Agent.js +1 -0
- package/bin/agent/Agent.js.map +1 -1
- package/bin/agent/AgentSessions.d.ts +2 -0
- package/bin/agent/AgentSessions.d.ts.map +1 -1
- package/bin/agent/AgentSessions.js +9 -0
- package/bin/agent/AgentSessions.js.map +1 -1
- package/bin/executor/messages/SessionAttachmentMapper.d.ts +15 -7
- package/bin/executor/messages/SessionAttachmentMapper.d.ts.map +1 -1
- package/bin/executor/messages/SessionAttachmentMapper.js +22 -32
- package/bin/executor/messages/SessionAttachmentMapper.js.map +1 -1
- package/bin/executor/types/SessionHistoryMeta.d.ts +2 -0
- package/bin/executor/types/SessionHistoryMeta.d.ts.map +1 -1
- package/bin/index.d.ts +2 -1
- package/bin/index.d.ts.map +1 -1
- package/bin/index.js.map +1 -1
- package/bin/remote/RemoteSession.d.ts +2 -2
- package/bin/remote/RemoteSession.d.ts.map +1 -1
- package/bin/remote/RemoteSession.js +2 -2
- package/bin/remote/RemoteSession.js.map +1 -1
- package/bin/remote/RemoteTransport.d.ts +2 -2
- package/bin/remote/RemoteTransport.d.ts.map +1 -1
- package/bin/remote/transports/HttpRemoteAgentTransport.d.ts +2 -2
- package/bin/remote/transports/HttpRemoteAgentTransport.d.ts.map +1 -1
- package/bin/remote/transports/HttpRemoteAgentTransport.js +2 -2
- package/bin/remote/transports/HttpRemoteAgentTransport.js.map +1 -1
- package/bin/remote/transports/RpcClient.d.ts +2 -2
- package/bin/remote/transports/RpcClient.d.ts.map +1 -1
- package/bin/remote/transports/RpcClient.js +2 -2
- package/bin/remote/transports/RpcClient.js.map +1 -1
- package/bin/remote/transports/RpcRemoteAgentTransport.d.ts +2 -2
- package/bin/remote/transports/RpcRemoteAgentTransport.d.ts.map +1 -1
- package/bin/remote/transports/RpcRemoteAgentTransport.js +2 -2
- package/bin/remote/transports/RpcRemoteAgentTransport.js.map +1 -1
- package/bin/session/Session.d.ts +6 -4
- package/bin/session/Session.d.ts.map +1 -1
- package/bin/session/Session.js +56 -31
- package/bin/session/Session.js.map +1 -1
- package/bin/session/SessionLoop.d.ts.map +1 -1
- package/bin/session/SessionLoop.js +6 -4
- package/bin/session/SessionLoop.js.map +1 -1
- package/bin/session/SessionMessages.d.ts +9 -3
- package/bin/session/SessionMessages.d.ts.map +1 -1
- package/bin/session/SessionMessages.js +35 -21
- package/bin/session/SessionMessages.js.map +1 -1
- package/bin/session/SessionState.d.ts +23 -10
- package/bin/session/SessionState.d.ts.map +1 -1
- package/bin/session/SessionState.js +129 -51
- package/bin/session/SessionState.js.map +1 -1
- package/bin/session/SessionTitle.d.ts +4 -0
- package/bin/session/SessionTitle.d.ts.map +1 -1
- package/bin/session/SessionTitle.js +5 -1
- package/bin/session/SessionTitle.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 +4 -4
- package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
- package/bin/session/runtime/SessionTitleTask.d.ts +35 -0
- package/bin/session/runtime/SessionTitleTask.d.ts.map +1 -0
- package/bin/session/runtime/SessionTitleTask.js +72 -0
- package/bin/session/runtime/SessionTitleTask.js.map +1 -0
- package/bin/session/storage/Metadata.d.ts.map +1 -1
- package/bin/session/storage/Metadata.js +7 -0
- package/bin/session/storage/Metadata.js.map +1 -1
- package/bin/types/agent/SessionActor.d.ts +3 -3
- package/bin/types/agent/SessionActor.d.ts.map +1 -1
- package/bin/types/agent/SessionTypes.d.ts +20 -0
- package/bin/types/agent/SessionTypes.d.ts.map +1 -1
- package/bin/types/rpc/RpcProtocol.d.ts +2 -1
- package/bin/types/rpc/RpcProtocol.d.ts.map +1 -1
- package/bin/types/sdk/AgentSessionPrompt.d.ts +22 -0
- package/bin/types/sdk/AgentSessionPrompt.d.ts.map +1 -1
- package/bin/types/sdk/AgentSessionPrompt.js.map +1 -1
- package/bin/types/session/SessionCommand.d.ts +2 -0
- package/bin/types/session/SessionCommand.d.ts.map +1 -1
- package/bin/types/session/SessionLocalState.d.ts +3 -0
- package/bin/types/session/SessionLocalState.d.ts.map +1 -1
- package/bin/types/session/SessionMessages.d.ts +5 -0
- package/bin/types/session/SessionMessages.d.ts.map +1 -1
- package/bin/types/session/SessionOptions.d.ts +2 -0
- package/bin/types/session/SessionOptions.d.ts.map +1 -1
- package/bin/types/store/SessionAttachmentStore.d.ts +29 -0
- package/bin/types/store/SessionAttachmentStore.d.ts.map +1 -0
- package/bin/types/store/SessionAttachmentStore.js +15 -0
- package/bin/types/store/SessionAttachmentStore.js.map +1 -0
- package/bin/types/store/SessionStore.d.ts +3 -0
- package/bin/types/store/SessionStore.d.ts.map +1 -1
- package/bin/workspace/store/LocalSessionAttachmentStore.d.ts +25 -0
- package/bin/workspace/store/LocalSessionAttachmentStore.d.ts.map +1 -0
- package/bin/workspace/store/LocalSessionAttachmentStore.js +72 -0
- package/bin/workspace/store/LocalSessionAttachmentStore.js.map +1 -0
- package/bin/workspace/store/LocalSessionStore.d.ts +3 -0
- package/bin/workspace/store/LocalSessionStore.d.ts.map +1 -1
- package/bin/workspace/store/LocalSessionStore.js +8 -1
- package/bin/workspace/store/LocalSessionStore.js.map +1 -1
- package/bin/workspace/store/LocalStorePaths.d.ts +6 -0
- package/bin/workspace/store/LocalStorePaths.d.ts.map +1 -1
- package/bin/workspace/store/LocalStorePaths.js +8 -0
- package/bin/workspace/store/LocalStorePaths.js.map +1 -1
- package/package.json +6 -6
- package/scripts/remote-session-reconnect.test.mjs +25 -0
- package/scripts/session-attachments.test.mjs +101 -0
- package/scripts/session-config-turn-boundary.test.mjs +185 -0
- package/scripts/session-list-title.test.mjs +6 -0
- package/scripts/session-title-event.test.mjs +67 -3
- package/scripts/session-turn-failure.test.mjs +34 -1
- package/src/agent/Agent.ts +1 -0
- package/src/agent/AgentSessions.ts +10 -0
- package/src/executor/messages/SessionAttachmentMapper.ts +24 -37
- package/src/executor/types/SessionHistoryMeta.ts +2 -0
- package/src/index.ts +2 -0
- package/src/remote/RemoteSession.ts +6 -2
- package/src/remote/RemoteTransport.ts +6 -1
- package/src/remote/transports/HttpRemoteAgentTransport.ts +3 -1
- package/src/remote/transports/RpcClient.ts +3 -1
- package/src/remote/transports/RpcRemoteAgentTransport.ts +3 -1
- package/src/session/Session.ts +65 -32
- package/src/session/SessionLoop.ts +6 -3
- package/src/session/SessionMessages.ts +61 -20
- package/src/session/SessionState.ts +150 -59
- package/src/session/SessionTitle.ts +13 -1
- package/src/session/messages/SessionAssistantMessageWriter.ts +7 -3
- package/src/session/runtime/SessionTitleTask.ts +86 -0
- package/src/session/storage/Metadata.ts +10 -0
- package/src/types/agent/SessionActor.ts +3 -2
- package/src/types/agent/SessionTypes.ts +22 -0
- package/src/types/rpc/RpcProtocol.ts +6 -1
- package/src/types/sdk/AgentSessionPrompt.ts +22 -0
- package/src/types/session/SessionCommand.ts +2 -0
- package/src/types/session/SessionLocalState.ts +4 -0
- package/src/types/session/SessionMessages.ts +5 -0
- package/src/types/session/SessionOptions.ts +3 -0
- package/src/types/store/SessionAttachmentStore.ts +29 -0
- package/src/types/store/SessionStore.ts +4 -0
- package/src/workspace/store/LocalSessionAttachmentStore.ts +96 -0
- package/src/workspace/store/LocalSessionStore.ts +13 -0
- package/src/workspace/store/LocalStorePaths.ts +16 -0
- 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
|
-
|
|
146
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/src/agent/Agent.ts
CHANGED
|
@@ -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
|
-
* -
|
|
7
|
-
* -
|
|
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
|
|
119
|
+
* 在用户 prompt 入库前,将 Data URL file part 保存为 Session 附件路径。
|
|
136
120
|
*
|
|
137
121
|
* 关键点(中文)
|
|
138
|
-
* -
|
|
139
|
-
* -
|
|
140
|
-
* - 本地文件读取失败时直接报错,避免模型请求拿到不可访问的本地路径。
|
|
122
|
+
* - 只有 Data URL 会在此处落盘;远程 URL 和本地路径保持引用不变。
|
|
123
|
+
* - 附件成功落盘后才把 URL 替换为相对 Workspace 根目录的路径。
|
|
141
124
|
*/
|
|
142
|
-
export async function
|
|
125
|
+
export async function persist_user_prompt_file_parts(
|
|
143
126
|
parts: SessionUserMessagePart[],
|
|
144
|
-
|
|
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
|
|
157
|
-
if (!
|
|
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
|
|
163
|
-
|
|
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(
|
|
163
|
-
|
|
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(
|
|
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(
|