create-windy 0.8.1 → 0.8.3
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/dist/cli.js +52 -34
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/agent-server/src/southwind_agent_server/attempt_context.py +9 -1
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +5 -2
- package/template/apps/agent-server/src/southwind_agent_server/provider_output_guidance.py +34 -0
- package/template/apps/agent-server/src/southwind_agent_server/structured_logging.py +16 -5
- package/template/apps/agent-server/tests/test_structured_logging.py +37 -0
- package/template/apps/agent-server/tests/test_tool_structured_output.py +347 -0
- package/template/apps/server/src/agent/admin-catalog-bootstrap.ts +25 -0
- package/template/apps/server/src/agent/agent-session-bootstrap.ts +7 -8
- package/template/apps/server/src/agent/agent-session-facade.ts +12 -0
- package/template/apps/server/src/agent/agent-session-validation.ts +43 -0
- package/template/apps/server/src/agent/operation-catalog.test.ts +202 -0
- package/template/apps/server/src/agent/operation-catalog.ts +135 -0
- package/template/apps/server/src/agent/run-browser-visibility.test.ts +59 -0
- package/template/apps/server/src/agent/run-browser-visibility.ts +11 -0
- package/template/apps/server/src/agent/run-operation-policy.ts +1 -0
- package/template/apps/server/src/agent/run-route-response.ts +35 -0
- package/template/apps/server/src/agent/run-routes.ts +46 -43
- package/template/apps/server/src/agent/runtime-bootstrap.ts +21 -20
- package/template/apps/server/src/agent/runtime-disabled-composition.ts +28 -0
- package/template/apps/server/src/agent/session-projection-facade.ts +8 -9
- package/template/apps/server/src/agent/session-projection-governance.ts +10 -0
- package/template/apps/server/src/agent/session-projection-view.ts +19 -16
- package/template/apps/server/src/agent/session-recording-reader.contract.test.ts +131 -0
- package/template/apps/server/src/agent/session-recording-reader.ts +62 -0
- package/template/apps/server/src/agent/tool-catalog-audit.ts +22 -0
- package/template/apps/server/src/agent/tool-catalog-availability.ts +21 -0
- package/template/apps/server/src/agent/tool-catalog-routes.test.ts +103 -0
- package/template/apps/server/src/agent/tool-catalog-routes.ts +62 -0
- package/template/apps/server/src/agent/tool-catalog.test.ts +122 -0
- package/template/apps/server/src/agent/tool-catalog.ts +44 -0
- package/template/apps/server/src/agent/tool-registry.ts +45 -14
- package/template/apps/server/src/agent/workbench-bootstrap.ts +39 -0
- package/template/apps/server/src/agent/workbench-event-projection.ts +115 -0
- package/template/apps/server/src/agent/workbench-facade.contract.test.ts +189 -0
- package/template/apps/server/src/agent/workbench-facade.ts +339 -0
- package/template/apps/server/src/agent/workbench-recording.contract.test.ts +328 -0
- package/template/apps/server/src/agent/workbench-routes.ts +97 -0
- package/template/apps/server/src/agent/workbench-run-integration.test.ts +231 -0
- package/template/apps/server/src/agent/workbench-run-reader.ts +187 -0
- package/template/apps/server/src/agent/workbench-safe-result.ts +61 -0
- package/template/apps/server/src/agent/workbench-sse.ts +50 -0
- package/template/apps/server/src/agent-workflow/query-service.ts +36 -0
- package/template/apps/server/src/agent-workflow/recording-projection.contract.test.ts +196 -0
- package/template/apps/server/src/agent-workflow/recording-projection.ts +86 -0
- package/template/apps/web/src/pages/system/AgentDebugPage.vue +14 -0
- package/template/apps/web/src/pages/system/AgentSessionPage.vue +29 -8
- package/template/apps/web/src/pages/system/AgentToolsPage.vue +76 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/AgentWorkbench.vue +236 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/DraftLeaveDialog.vue +34 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/ReplayEventDetail.vue +175 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/SessionReplayTimeline.vue +142 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/SessionReplayTimeline.webtest.ts +242 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/TestRunComposer.vue +97 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/ToolCatalogPanel.vue +100 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/ToolCatalogPanel.webtest.ts +55 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/ToolRuleDetail.vue +81 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/WorkbenchSessionList.vue +57 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/catalog-presenter.ts +20 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/replay-presenter.ts +94 -0
- package/template/apps/web/src/pages/system/composables/useAgentWorkbench.ts +298 -0
- package/template/apps/web/src/pages/system/composables/useAgentWorkbench.webtest.ts +164 -0
- package/template/apps/web/src/pages/system/composables/useSessionReplay.ts +48 -0
- package/template/apps/web/src/router/index.ts +4 -0
- package/template/apps/web/src/router/platform-admin-route-bindings.ts +4 -0
- package/template/apps/web/src/services/agent-admin-api.ts +155 -0
- package/template/apps/web/src/services/agent-admin-api.webtest.ts +120 -0
- package/template/apps/web/src/services/agent-workbench-event.ts +60 -0
- package/template/docs/architecture/agent-harness.md +4 -0
- package/template/docs/architecture/log-governance-implementation.md +20 -3
- package/template/docs/architecture/log-governance-plan.md +2 -2
- package/template/docs/platform/agent-runtime.md +18 -0
- package/template/packages/modules/src/ai-operation-composition.ts +9 -0
- package/template/packages/modules/src/ai-operation-visibility.test.ts +33 -0
- package/template/packages/modules/src/manifest.ts +2 -0
- package/template/packages/modules/src/system-admin-routes.ts +16 -0
- package/template/packages/modules/src/system-admin-ui-catalog.ts +7 -1
- package/template/packages/modules/src/system-agent-admin-bindings.ts +42 -0
- package/template/packages/modules/src/system-agent-admin-menus.ts +26 -0
- package/template/packages/modules/src/system-agent-admin-registration.test.ts +52 -0
- package/template/packages/modules/src/system-agent-audit-actions.ts +5 -0
- package/template/packages/modules/src/system-ai-api-bindings.ts +2 -0
- package/template/packages/modules/src/system-audit-actions.ts +25 -0
- package/template/packages/modules/src/system-features.ts +19 -0
- package/template/packages/modules/src/system-module-catalog.ts +14 -12
- package/template/packages/modules/src/system-module-ownership.ts +12 -0
- package/template/packages/modules/src/system-modules.ts +2 -0
- package/template/packages/modules/src/system-permissions.ts +5 -0
- package/template/packages/server-sdk/index.ts +2 -0
- package/template/packages/server-sdk/src/agent-session-provider.ts +19 -0
- package/template/packages/shared/index.ts +2 -0
- package/template/packages/shared/src/agent-admin-catalog.ts +73 -0
- package/template/packages/shared/src/agent-workbench.ts +100 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { AgentRuntimePort } from "./run-contracts.js";
|
|
3
|
+
import {
|
|
4
|
+
createRunFacadeFixture,
|
|
5
|
+
guardContext,
|
|
6
|
+
} from "./run-facade.test-support.js";
|
|
7
|
+
import {
|
|
8
|
+
AgentWorkbenchRunReader,
|
|
9
|
+
readRecordedRunEvents,
|
|
10
|
+
} from "./workbench-run-reader.js";
|
|
11
|
+
import { projectPublicRunEvents } from "./workbench-sse.js";
|
|
12
|
+
import { projectWorkbenchResult } from "./workbench-safe-result.js";
|
|
13
|
+
import { validateAgentSessionDetail } from "./agent-session-validation.js";
|
|
14
|
+
|
|
15
|
+
const records = [
|
|
16
|
+
{ sequence: 1, type: "run.started", runId: "run_1", rawBody: "SECRET" },
|
|
17
|
+
{
|
|
18
|
+
sequence: 2,
|
|
19
|
+
type: "tool.requested",
|
|
20
|
+
toolCallId: "call_1",
|
|
21
|
+
toolKey: "business.records.read",
|
|
22
|
+
arguments: { password: "SECRET" },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
sequence: 3,
|
|
26
|
+
type: "approval.required",
|
|
27
|
+
toolCallId: "call_1",
|
|
28
|
+
workItemId: "item_1",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
sequence: 4,
|
|
32
|
+
type: "approval.resolved",
|
|
33
|
+
toolCallId: "call_1",
|
|
34
|
+
workItemId: "item_1",
|
|
35
|
+
outcome: "approved",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
sequence: 5,
|
|
39
|
+
type: "tool.completed",
|
|
40
|
+
toolCallId: "call_1",
|
|
41
|
+
result: "SECRET",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
sequence: 6,
|
|
45
|
+
type: "usage.reported",
|
|
46
|
+
attemptSequence: 2,
|
|
47
|
+
usage: {
|
|
48
|
+
inputTokens: 15,
|
|
49
|
+
outputTokens: 6,
|
|
50
|
+
totalTokens: 21,
|
|
51
|
+
providerBody: "SECRET",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{ sequence: 7, type: "output.delta", text: "受控回答" },
|
|
55
|
+
{ sequence: 8, type: "run.completed" },
|
|
56
|
+
];
|
|
57
|
+
const encoder = new TextEncoder();
|
|
58
|
+
function frames(events: readonly object[], crlf = false) {
|
|
59
|
+
const content = events
|
|
60
|
+
.map((event) => `data: ${JSON.stringify(event)}\n\n`)
|
|
61
|
+
.join("");
|
|
62
|
+
return crlf ? content.replaceAll("\n", "\r\n") : content;
|
|
63
|
+
}
|
|
64
|
+
function remote(content = frames(records)): AgentRuntimePort {
|
|
65
|
+
return {
|
|
66
|
+
start: async () => {
|
|
67
|
+
throw new Error("must never start");
|
|
68
|
+
},
|
|
69
|
+
cancel: async () => {
|
|
70
|
+
throw new Error("must never cancel");
|
|
71
|
+
},
|
|
72
|
+
health: async () => new Response(),
|
|
73
|
+
events: async () => new Response(content),
|
|
74
|
+
status: async (runId) => ({
|
|
75
|
+
runId,
|
|
76
|
+
status: "completed",
|
|
77
|
+
lastSequence: records.length,
|
|
78
|
+
}),
|
|
79
|
+
result: async () =>
|
|
80
|
+
Response.json({
|
|
81
|
+
status: "completed",
|
|
82
|
+
output: {
|
|
83
|
+
answer: "业务结果",
|
|
84
|
+
secret: "SECRET",
|
|
85
|
+
nested: { reasoning: "SECRET", total: 12 },
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("Workbench immutable recorded Run read port", () => {
|
|
92
|
+
test("current actor authorization precedes read, no start/cancel/Memory writes; Tool/Approval/Attempt order survives", async () => {
|
|
93
|
+
const fixture = await createRunFacadeFixture();
|
|
94
|
+
await fixture.facade.start(
|
|
95
|
+
"business.answer",
|
|
96
|
+
{ question: "测试" },
|
|
97
|
+
"unique",
|
|
98
|
+
fixture.context,
|
|
99
|
+
);
|
|
100
|
+
const reader = new AgentWorkbenchRunReader(fixture.facade, remote());
|
|
101
|
+
const result = await reader.read(
|
|
102
|
+
{ runId: "run_1", operationKey: "business.answer" },
|
|
103
|
+
fixture.context,
|
|
104
|
+
new AbortController().signal,
|
|
105
|
+
);
|
|
106
|
+
expect(result.correlationStatus).toBe("complete");
|
|
107
|
+
expect(result.events.map(({ sequence }) => sequence)).toEqual([
|
|
108
|
+
1, 2, 3, 4, 5, 6, 7, 8,
|
|
109
|
+
]);
|
|
110
|
+
expect(result.events[2]?.workItemId).toBe("item_1");
|
|
111
|
+
expect(result.events[5]?.attemptSequence).toBe(2);
|
|
112
|
+
expect(result.events[5]?.usage?.totalTokens).toBe(21);
|
|
113
|
+
expect(result.result?.output).toEqual({
|
|
114
|
+
answer: "业务结果",
|
|
115
|
+
nested: { total: 12 },
|
|
116
|
+
});
|
|
117
|
+
expect(JSON.stringify(result)).not.toContain("SECRET");
|
|
118
|
+
expect(fixture.remote.started).toHaveLength(1);
|
|
119
|
+
const denied = await reader.read(
|
|
120
|
+
{ runId: "run_1", operationKey: "business.answer" },
|
|
121
|
+
guardContext([]),
|
|
122
|
+
new AbortController().signal,
|
|
123
|
+
);
|
|
124
|
+
expect(denied.missing).toEqual(["RUN_NOT_ACCESSIBLE"]);
|
|
125
|
+
const wrongSession = await reader.read(
|
|
126
|
+
{
|
|
127
|
+
runId: "run_1",
|
|
128
|
+
operationKey: "business.answer",
|
|
129
|
+
sessionId: "session_other",
|
|
130
|
+
},
|
|
131
|
+
fixture.context,
|
|
132
|
+
new AbortController().signal,
|
|
133
|
+
);
|
|
134
|
+
expect(wrongSession.missing).toEqual(["RUN_NOT_ACCESSIBLE"]);
|
|
135
|
+
});
|
|
136
|
+
test("internal Operations never expose answers and partial/empty recordings are explicit", async () => {
|
|
137
|
+
const fixture = await createRunFacadeFixture();
|
|
138
|
+
const internal = new AgentWorkbenchRunReader(
|
|
139
|
+
{
|
|
140
|
+
authorizeRun: fixture.facade.authorizeRun.bind(fixture.facade),
|
|
141
|
+
operationPolicy: (key) => {
|
|
142
|
+
const policy = fixture.facade.operationPolicy(key);
|
|
143
|
+
return policy
|
|
144
|
+
? { ...policy, visibility: "internal" as const }
|
|
145
|
+
: undefined;
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
remote(),
|
|
149
|
+
);
|
|
150
|
+
expect(
|
|
151
|
+
(
|
|
152
|
+
await internal.read(
|
|
153
|
+
{ runId: "run_1", operationKey: "business.answer" },
|
|
154
|
+
fixture.context,
|
|
155
|
+
new AbortController().signal,
|
|
156
|
+
)
|
|
157
|
+
).missing,
|
|
158
|
+
).toEqual(["RUN_NOT_ACCESSIBLE"]);
|
|
159
|
+
await fixture.facade.start(
|
|
160
|
+
"business.answer",
|
|
161
|
+
{ question: "测试" },
|
|
162
|
+
"unique",
|
|
163
|
+
fixture.context,
|
|
164
|
+
);
|
|
165
|
+
const port = remote();
|
|
166
|
+
const empty = new AgentWorkbenchRunReader(fixture.facade, {
|
|
167
|
+
...port,
|
|
168
|
+
status: async (runId) => ({
|
|
169
|
+
runId,
|
|
170
|
+
status: "completed",
|
|
171
|
+
lastSequence: 0,
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
expect(
|
|
175
|
+
(
|
|
176
|
+
await empty.read(
|
|
177
|
+
{ runId: "run_1", operationKey: "business.answer" },
|
|
178
|
+
fixture.context,
|
|
179
|
+
new AbortController().signal,
|
|
180
|
+
)
|
|
181
|
+
).missing,
|
|
182
|
+
).toEqual(["RUN_EVENTS_MISSING"]);
|
|
183
|
+
expect(
|
|
184
|
+
(
|
|
185
|
+
await readRecordedRunEvents(
|
|
186
|
+
remote(frames([records[0]!, records[2]!])),
|
|
187
|
+
"run_1",
|
|
188
|
+
3,
|
|
189
|
+
new AbortController().signal,
|
|
190
|
+
)
|
|
191
|
+
).missing,
|
|
192
|
+
).toEqual(["RUN_EVENTS_PARTIAL"]);
|
|
193
|
+
});
|
|
194
|
+
test("many complete frames within one large chunk and split CRLF remain exact ordered facts", async () => {
|
|
195
|
+
const events = Array.from({ length: 1500 }, (_, index) => ({
|
|
196
|
+
sequence: index + 1,
|
|
197
|
+
type: "tool.requested",
|
|
198
|
+
toolCallId: `call_${index}`,
|
|
199
|
+
toolKey: "business.records.read",
|
|
200
|
+
rawBody: "x".repeat(160),
|
|
201
|
+
}));
|
|
202
|
+
const content = frames(events, true);
|
|
203
|
+
const parts = [
|
|
204
|
+
content.slice(0, content.indexOf("\r\n") + 1),
|
|
205
|
+
content.slice(content.indexOf("\r\n") + 1),
|
|
206
|
+
];
|
|
207
|
+
const port = {
|
|
208
|
+
...remote(),
|
|
209
|
+
events: async () =>
|
|
210
|
+
new Response(
|
|
211
|
+
new ReadableStream({
|
|
212
|
+
start(controller) {
|
|
213
|
+
for (const part of parts)
|
|
214
|
+
controller.enqueue(encoder.encode(part));
|
|
215
|
+
controller.close();
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
),
|
|
219
|
+
};
|
|
220
|
+
const read = await readRecordedRunEvents(
|
|
221
|
+
port,
|
|
222
|
+
"run_1",
|
|
223
|
+
1500,
|
|
224
|
+
new AbortController().signal,
|
|
225
|
+
);
|
|
226
|
+
expect(read.events).toHaveLength(1500);
|
|
227
|
+
expect(read.missing).toEqual([]);
|
|
228
|
+
const stream = projectPublicRunEvents(await port.events());
|
|
229
|
+
const data = await stream.text();
|
|
230
|
+
expect(data.match(/^id:/gm)).toHaveLength(1500);
|
|
231
|
+
expect(data).not.toContain("rawBody");
|
|
232
|
+
});
|
|
233
|
+
test("public SSE keeps normal answer, usage and approval fields but strips arbitrary sensitive payloads", async () => {
|
|
234
|
+
const publicStream = projectPublicRunEvents(new Response(frames(records)));
|
|
235
|
+
const events = (await publicStream.text())
|
|
236
|
+
.split("\n\n")
|
|
237
|
+
.filter(Boolean)
|
|
238
|
+
.map(
|
|
239
|
+
(frame) =>
|
|
240
|
+
JSON.parse(
|
|
241
|
+
frame
|
|
242
|
+
.split("\n")
|
|
243
|
+
.find((line) => line.startsWith("data:"))!
|
|
244
|
+
.slice(6),
|
|
245
|
+
) as Record<string, unknown>,
|
|
246
|
+
);
|
|
247
|
+
expect(events[6]?.text).toBe("受控回答");
|
|
248
|
+
expect(events[2]?.workItemId).toBe("item_1");
|
|
249
|
+
expect(events[3]?.outcome).toBe("approved");
|
|
250
|
+
expect(events[5]?.usage).toEqual({
|
|
251
|
+
inputTokens: 15,
|
|
252
|
+
outputTokens: 6,
|
|
253
|
+
totalTokens: 21,
|
|
254
|
+
});
|
|
255
|
+
expect(JSON.stringify(events)).not.toContain("SECRET");
|
|
256
|
+
});
|
|
257
|
+
test("safe result recursively removes private state while keeping legitimate business JSON", () => {
|
|
258
|
+
expect(
|
|
259
|
+
projectWorkbenchResult({
|
|
260
|
+
output: {
|
|
261
|
+
currency: "CNY",
|
|
262
|
+
total: 12,
|
|
263
|
+
chain_of_thought: "hidden",
|
|
264
|
+
details: [{ arguments: { secret: "hidden" }, amount: 5 }],
|
|
265
|
+
},
|
|
266
|
+
providerBody: "hidden",
|
|
267
|
+
}),
|
|
268
|
+
).toEqual({
|
|
269
|
+
output: { currency: "CNY", total: 12, details: [{ amount: 5 }] },
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
test("provider references are bounded, unique and point to messages actually returned", () => {
|
|
273
|
+
const base = {
|
|
274
|
+
sourceId: "1",
|
|
275
|
+
title: "业务会话",
|
|
276
|
+
updatedAt: "2026-10-07T01:00:00Z",
|
|
277
|
+
revision: "1",
|
|
278
|
+
messages: [
|
|
279
|
+
{
|
|
280
|
+
sequence: 2,
|
|
281
|
+
role: "assistant" as const,
|
|
282
|
+
content: "回答",
|
|
283
|
+
createdAt: "2026-10-07T01:00:00Z",
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
};
|
|
287
|
+
expect(
|
|
288
|
+
validateAgentSessionDetail(
|
|
289
|
+
{
|
|
290
|
+
...base,
|
|
291
|
+
truncated: true,
|
|
292
|
+
runRefs: [
|
|
293
|
+
{
|
|
294
|
+
runId: "run_1",
|
|
295
|
+
operationKey: "business.answer",
|
|
296
|
+
messageSequence: 2,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
"1",
|
|
301
|
+
).truncated,
|
|
302
|
+
).toBe(true);
|
|
303
|
+
expect(() =>
|
|
304
|
+
validateAgentSessionDetail(
|
|
305
|
+
{
|
|
306
|
+
...base,
|
|
307
|
+
runRefs: [
|
|
308
|
+
{
|
|
309
|
+
runId: "run_1",
|
|
310
|
+
operationKey: "business.answer",
|
|
311
|
+
messageSequence: 9,
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
"1",
|
|
316
|
+
),
|
|
317
|
+
).toThrow();
|
|
318
|
+
expect(() =>
|
|
319
|
+
validateAgentSessionDetail(
|
|
320
|
+
{
|
|
321
|
+
...base,
|
|
322
|
+
workflowRefs: [{ workflowRunId: "wf_1" }, { workflowRunId: "wf_1" }],
|
|
323
|
+
},
|
|
324
|
+
"1",
|
|
325
|
+
),
|
|
326
|
+
).toThrow();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { RequestGuardContext } from "../guards.js";
|
|
2
|
+
import type { RouteApp } from "../system/routes.js";
|
|
3
|
+
import { AgentSessionFacadeError } from "./agent-session-facade.js";
|
|
4
|
+
import { AgentSessionProjectionError } from "./session-projection-facade.js";
|
|
5
|
+
import {
|
|
6
|
+
AgentWorkbenchError,
|
|
7
|
+
type AgentWorkbenchFacade,
|
|
8
|
+
} from "./workbench-facade.js";
|
|
9
|
+
|
|
10
|
+
interface Context {
|
|
11
|
+
request: Request;
|
|
12
|
+
params: Record<string, string | undefined>;
|
|
13
|
+
guardContext: RequestGuardContext;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function registerAgentWorkbenchRoutes(
|
|
17
|
+
app: RouteApp,
|
|
18
|
+
facade: AgentWorkbenchFacade,
|
|
19
|
+
) {
|
|
20
|
+
app.get("/agent/admin/session-recordings", (raw) =>
|
|
21
|
+
handle(raw, (context) =>
|
|
22
|
+
facade.list(
|
|
23
|
+
source(context.request),
|
|
24
|
+
context.guardContext,
|
|
25
|
+
context.request.signal,
|
|
26
|
+
),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
app.get("/agent/admin/session-recordings/:sessionId", (raw) =>
|
|
30
|
+
handle(raw, (context) => {
|
|
31
|
+
const id = context.params.sessionId;
|
|
32
|
+
if (!id || !/^[A-Za-z0-9_.:-]{1,250}$/.test(id)) throw new InputError();
|
|
33
|
+
return facade.recording(
|
|
34
|
+
source(context.request),
|
|
35
|
+
id,
|
|
36
|
+
context.guardContext,
|
|
37
|
+
context.request.signal,
|
|
38
|
+
);
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
app.post("/agent/admin/test-sessions", (raw) =>
|
|
42
|
+
handle(raw, (context) => facade.createTestSession(context.guardContext)),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function handle(
|
|
47
|
+
raw: unknown,
|
|
48
|
+
action: (context: Context) => Promise<unknown>,
|
|
49
|
+
) {
|
|
50
|
+
const context = raw as Context;
|
|
51
|
+
try {
|
|
52
|
+
return json(await action(context));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (
|
|
55
|
+
error instanceof AgentWorkbenchError ||
|
|
56
|
+
error instanceof AgentSessionFacadeError ||
|
|
57
|
+
error instanceof AgentSessionProjectionError
|
|
58
|
+
)
|
|
59
|
+
return json(
|
|
60
|
+
{
|
|
61
|
+
code: error.code,
|
|
62
|
+
requestId: context.guardContext.requestId,
|
|
63
|
+
retryable: error.status >= 500,
|
|
64
|
+
},
|
|
65
|
+
error.status,
|
|
66
|
+
);
|
|
67
|
+
if (error instanceof InputError)
|
|
68
|
+
return json(
|
|
69
|
+
{
|
|
70
|
+
code: "INVALID_INPUT",
|
|
71
|
+
requestId: context.guardContext.requestId,
|
|
72
|
+
retryable: false,
|
|
73
|
+
},
|
|
74
|
+
400,
|
|
75
|
+
);
|
|
76
|
+
return json(
|
|
77
|
+
{
|
|
78
|
+
code: "WORKBENCH_UNAVAILABLE",
|
|
79
|
+
requestId: context.guardContext.requestId,
|
|
80
|
+
retryable: true,
|
|
81
|
+
},
|
|
82
|
+
503,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function source(request: Request): "business" | "harness" {
|
|
87
|
+
const value = new URL(request.url).searchParams.get("source") ?? "harness";
|
|
88
|
+
if (value !== "harness" && value !== "business") throw new InputError();
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
function json(value: unknown, status = 200) {
|
|
92
|
+
return Response.json(value, {
|
|
93
|
+
status,
|
|
94
|
+
headers: { "cache-control": "no-store" },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
class InputError extends Error {}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { AgentWorkbenchRecordingV1 } from "@southwind-ai/shared";
|
|
2
|
+
import type { SessionId } from "./session-log/domain.js";
|
|
3
|
+
import { expect, test } from "bun:test";
|
|
4
|
+
import { Elysia } from "elysia";
|
|
5
|
+
import { systemModuleManifest } from "@southwind-ai/modules";
|
|
6
|
+
import { createServerRuntime } from "../runtime.js";
|
|
7
|
+
import { AgentRunFacade } from "./run-facade.js";
|
|
8
|
+
import {
|
|
9
|
+
createRunFacadeFixture,
|
|
10
|
+
guardContext,
|
|
11
|
+
runFacadeManifest,
|
|
12
|
+
} from "./run-facade.test-support.js";
|
|
13
|
+
import { createTestRunAuthorizationStore } from "./run-authorization.test-support.js";
|
|
14
|
+
import { registerAgentRunRoutes } from "./run-routes.js";
|
|
15
|
+
import type { AgentRuntimePort } from "./run-contracts.js";
|
|
16
|
+
import type { RestrictedAgentToolHost } from "./tool-host.js";
|
|
17
|
+
import { InMemorySessionEventRepository } from "./session-log/in-memory-repository.js";
|
|
18
|
+
import { SessionEventLogService } from "./session-log/service.js";
|
|
19
|
+
import { AgentSessionProjectionFacade } from "./session-projection-facade.js";
|
|
20
|
+
import type { DrizzleSessionEventRepository } from "./session-persistence/drizzle-session-repository.js";
|
|
21
|
+
import type {
|
|
22
|
+
DrizzleContextProjectionReader,
|
|
23
|
+
DrizzleMemoryProjectionStore,
|
|
24
|
+
} from "./session-persistence/drizzle-context-memory.js";
|
|
25
|
+
import type { DrizzleSessionMutationCoordinator } from "./session-persistence/drizzle-session-mutations.js";
|
|
26
|
+
import { PersistentAgentRunSessionLink } from "./session-run-link.js";
|
|
27
|
+
import type {
|
|
28
|
+
SessionRunProjectionReceipt,
|
|
29
|
+
SessionRunResultMemoryPort,
|
|
30
|
+
} from "./session-result-memory.js";
|
|
31
|
+
import { AgentWorkbenchFacade } from "./workbench-facade.js";
|
|
32
|
+
import { AgentWorkbenchRunReader } from "./workbench-run-reader.js";
|
|
33
|
+
import { registerAgentWorkbenchRoutes } from "./workbench-routes.js";
|
|
34
|
+
|
|
35
|
+
test("Admin Session → normal governed Run → terminal settlement → read-only replay preserves ledger", async () => {
|
|
36
|
+
const fixture = await createRunFacadeFixture();
|
|
37
|
+
const modules = [systemModuleManifest("system.agent"), runFacadeManifest()];
|
|
38
|
+
const runtime = createServerRuntime(
|
|
39
|
+
{},
|
|
40
|
+
{
|
|
41
|
+
featureCatalog: modules.flatMap(({ features }) => features),
|
|
42
|
+
licenseResolver: { resolve: () => ({ status: "valid" }) },
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
const context = guardContext([
|
|
46
|
+
"system.agent-debug.read",
|
|
47
|
+
"system.agent-debug.manage",
|
|
48
|
+
"system.agent-session.manage",
|
|
49
|
+
"system.agent-session.read",
|
|
50
|
+
"business.ai.execute",
|
|
51
|
+
"business.records.read",
|
|
52
|
+
]);
|
|
53
|
+
// Exercise the production Session service and Run link with an in-memory persistence adapter.
|
|
54
|
+
const repository = new InMemorySessionEventRepository();
|
|
55
|
+
const dbRepository = repository as unknown as DrizzleSessionEventRepository;
|
|
56
|
+
const service = new SessionEventLogService(repository);
|
|
57
|
+
const sessions = new AgentSessionProjectionFacade(
|
|
58
|
+
dbRepository,
|
|
59
|
+
service,
|
|
60
|
+
{} as DrizzleContextProjectionReader,
|
|
61
|
+
{} as DrizzleMemoryProjectionStore,
|
|
62
|
+
{} as DrizzleSessionMutationCoordinator,
|
|
63
|
+
);
|
|
64
|
+
const receipts = new Map<string, SessionRunProjectionReceipt>();
|
|
65
|
+
const links = new PersistentAgentRunSessionLink(dbRepository, service, {
|
|
66
|
+
read: async (sessionId, runId) => receipts.get(`${sessionId}:${runId}`),
|
|
67
|
+
append: async (receipt) => {
|
|
68
|
+
receipts.set(`${receipt.sessionId}:${receipt.runId}`, receipt);
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
let resultsRead = 0;
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
const eventData = [
|
|
74
|
+
{ sequence: 1, type: "run.started" },
|
|
75
|
+
{
|
|
76
|
+
sequence: 2,
|
|
77
|
+
type: "tool.requested",
|
|
78
|
+
toolCallId: "call-1",
|
|
79
|
+
toolKey: "business.records.list",
|
|
80
|
+
arguments: { password: "private-canary" },
|
|
81
|
+
},
|
|
82
|
+
{ sequence: 3, type: "tool.completed", toolCallId: "call-1" },
|
|
83
|
+
{
|
|
84
|
+
sequence: 4,
|
|
85
|
+
type: "usage.reported",
|
|
86
|
+
attemptSequence: 1,
|
|
87
|
+
usage: { totalTokens: 12 },
|
|
88
|
+
},
|
|
89
|
+
{ sequence: 5, type: "run.completed" },
|
|
90
|
+
];
|
|
91
|
+
const remote: AgentRuntimePort = {
|
|
92
|
+
start: (...args) => fixture.remote.start(...args),
|
|
93
|
+
health: async () => new Response(),
|
|
94
|
+
status: async (runId) => ({
|
|
95
|
+
runId,
|
|
96
|
+
status: cancelled ? "cancelled" : "completed",
|
|
97
|
+
lastSequence: 5,
|
|
98
|
+
}),
|
|
99
|
+
events: async () =>
|
|
100
|
+
new Response(
|
|
101
|
+
eventData.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
|
|
102
|
+
),
|
|
103
|
+
result: async () => {
|
|
104
|
+
resultsRead++;
|
|
105
|
+
return Response.json({
|
|
106
|
+
status: "completed",
|
|
107
|
+
output: { answer: "受控测试回答" },
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
cancel: async () => {
|
|
111
|
+
cancelled = true;
|
|
112
|
+
return Response.json({ status: "cancelled" });
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
const resultMemory: SessionRunResultMemoryPort = {
|
|
116
|
+
hasProjection: async ({ sessionId, runId }) =>
|
|
117
|
+
receipts.has(`${sessionId}:${runId}`),
|
|
118
|
+
project: async ({ sessionId, runId, terminalSequence }) => {
|
|
119
|
+
const receipt: SessionRunProjectionReceipt = {
|
|
120
|
+
sessionId,
|
|
121
|
+
runId,
|
|
122
|
+
terminalSequence,
|
|
123
|
+
resultDigest: `sha256:${"a".repeat(64)}`,
|
|
124
|
+
memoryIds: ["memory-1", "memory-2"],
|
|
125
|
+
projectedAt: new Date().toISOString(),
|
|
126
|
+
};
|
|
127
|
+
receipts.set(`${sessionId}:${runId}`, receipt);
|
|
128
|
+
return receipt;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
const facade = new AgentRunFacade(
|
|
132
|
+
modules,
|
|
133
|
+
fixture.hosts,
|
|
134
|
+
runtime,
|
|
135
|
+
remote,
|
|
136
|
+
createTestRunAuthorizationStore(),
|
|
137
|
+
undefined,
|
|
138
|
+
undefined,
|
|
139
|
+
links,
|
|
140
|
+
);
|
|
141
|
+
const workbench = new AgentWorkbenchFacade({
|
|
142
|
+
modules,
|
|
143
|
+
runtime,
|
|
144
|
+
sessions,
|
|
145
|
+
runs: new AgentWorkbenchRunReader(facade, remote),
|
|
146
|
+
});
|
|
147
|
+
const app = new Elysia().derive(() => ({ guardContext: context }));
|
|
148
|
+
registerAgentWorkbenchRoutes(app, workbench);
|
|
149
|
+
registerAgentRunRoutes(
|
|
150
|
+
app,
|
|
151
|
+
facade,
|
|
152
|
+
remote,
|
|
153
|
+
{} as RestrictedAgentToolHost,
|
|
154
|
+
"test-internal",
|
|
155
|
+
links,
|
|
156
|
+
resultMemory,
|
|
157
|
+
);
|
|
158
|
+
const create = await app.handle(
|
|
159
|
+
new Request("http://localhost/agent/admin/test-sessions", {
|
|
160
|
+
method: "POST",
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
expect(create.status).toBe(200);
|
|
164
|
+
const { sessionId } = (await create.json()) as { sessionId: SessionId };
|
|
165
|
+
const start = await app.handle(
|
|
166
|
+
new Request("http://localhost/agent/runs", {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: {
|
|
169
|
+
"content-type": "application/json",
|
|
170
|
+
"idempotency-key": "test-request",
|
|
171
|
+
},
|
|
172
|
+
body: JSON.stringify({
|
|
173
|
+
operationKey: "business.answer",
|
|
174
|
+
input: { question: "测试" },
|
|
175
|
+
sessionId,
|
|
176
|
+
}),
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
expect(start.status).toBe(200);
|
|
180
|
+
expect(await start.json()).toMatchObject({ runId: "run_1" });
|
|
181
|
+
expect(
|
|
182
|
+
(await repository.read({ sessionId, afterSequence: 0, limit: 100 })).some(
|
|
183
|
+
({ data }) => data.type === "run.linked" && data.runId === "run_1",
|
|
184
|
+
),
|
|
185
|
+
).toBe(true);
|
|
186
|
+
expect(fixture.remote.started[0]?.executionGrant).toMatch(/^grant_/);
|
|
187
|
+
const query = new URLSearchParams({
|
|
188
|
+
operationKey: "business.answer",
|
|
189
|
+
sessionId,
|
|
190
|
+
});
|
|
191
|
+
const events = await app.handle(
|
|
192
|
+
new Request(`http://localhost/agent/runs/run_1/events?${query}`),
|
|
193
|
+
);
|
|
194
|
+
expect(events.status).toBe(200);
|
|
195
|
+
const text = await events.text();
|
|
196
|
+
expect(text).toContain("business.records.list");
|
|
197
|
+
expect(text).not.toContain("private-canary");
|
|
198
|
+
const result = await app.handle(
|
|
199
|
+
new Request(`http://localhost/agent/runs/run_1/result?${query}`),
|
|
200
|
+
);
|
|
201
|
+
expect(result.status).toBe(200);
|
|
202
|
+
expect(await result.json()).toMatchObject({
|
|
203
|
+
output: { answer: "受控测试回答" },
|
|
204
|
+
});
|
|
205
|
+
const before = (await repository.get(sessionId))!.headSequence;
|
|
206
|
+
const detail = await app.handle(
|
|
207
|
+
new Request(
|
|
208
|
+
`http://localhost/agent/admin/session-recordings/${sessionId}?source=harness`,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
expect(detail.status).toBe(200);
|
|
212
|
+
const recording = (await detail.json()) as AgentWorkbenchRecordingV1;
|
|
213
|
+
expect(recording).toMatchObject({
|
|
214
|
+
mode: "read-only",
|
|
215
|
+
sideEffectPolicy: "no-reexecution",
|
|
216
|
+
});
|
|
217
|
+
expect(recording.runs[0]).toMatchObject({
|
|
218
|
+
runId: "run_1",
|
|
219
|
+
operationKey: "business.answer",
|
|
220
|
+
status: "completed",
|
|
221
|
+
});
|
|
222
|
+
expect(
|
|
223
|
+
recording.runs[0].events.map(
|
|
224
|
+
(event: { sequence: number }) => event.sequence,
|
|
225
|
+
),
|
|
226
|
+
).toEqual([1, 2, 3, 4, 5]);
|
|
227
|
+
expect(JSON.stringify(recording)).not.toContain("private-canary");
|
|
228
|
+
expect((await repository.get(sessionId))!.headSequence).toBe(before);
|
|
229
|
+
expect(fixture.remote.started).toHaveLength(1);
|
|
230
|
+
expect(resultsRead).toBe(2);
|
|
231
|
+
});
|