create-windy 0.8.2 → 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 +18 -1
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- 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/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,164 @@
|
|
|
1
|
+
import { defineComponent, shallowRef } from "vue";
|
|
2
|
+
import { flushPromises, mount } from "@vue/test-utils";
|
|
3
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
4
|
+
import type { AgentWorkbenchRecordingV1 } from "@southwind-ai/shared";
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
list: vi.fn(),
|
|
7
|
+
operations: vi.fn(),
|
|
8
|
+
detail: vi.fn(),
|
|
9
|
+
session: vi.fn(),
|
|
10
|
+
run: vi.fn(),
|
|
11
|
+
stream: vi.fn(),
|
|
12
|
+
result: vi.fn(),
|
|
13
|
+
cancel: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock("@/services/agent-admin-api", () => ({
|
|
16
|
+
listWorkbenchRecordings: mocks.list,
|
|
17
|
+
listAdminOperations: mocks.operations,
|
|
18
|
+
getWorkbenchRecording: mocks.detail,
|
|
19
|
+
createTestSession: mocks.session,
|
|
20
|
+
createTestRun: mocks.run,
|
|
21
|
+
streamTestRun: mocks.stream,
|
|
22
|
+
getTestRunResult: mocks.result,
|
|
23
|
+
cancelTestRun: mocks.cancel,
|
|
24
|
+
}));
|
|
25
|
+
const fingerprint = shallowRef("actor-one");
|
|
26
|
+
vi.mock("@/composables/useAccessSnapshot", () => ({
|
|
27
|
+
useAccessSnapshot: () => ({
|
|
28
|
+
fingerprint,
|
|
29
|
+
snapshot: shallowRef({
|
|
30
|
+
actor: { permissionKeys: ["system.agent-debug.manage"] },
|
|
31
|
+
}),
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
vi.mock("@/composables/useAuthSession", () => ({
|
|
35
|
+
useAuthSession: () => ({ actor: shallowRef({ id: "actor-one" }) }),
|
|
36
|
+
}));
|
|
37
|
+
import { useAgentWorkbench } from "./useAgentWorkbench";
|
|
38
|
+
const history: AgentWorkbenchRecordingV1 = {
|
|
39
|
+
source: "harness",
|
|
40
|
+
sessionId: "session_test",
|
|
41
|
+
title: "测试",
|
|
42
|
+
updatedAt: "today",
|
|
43
|
+
mode: "read-only",
|
|
44
|
+
sideEffectPolicy: "no-reexecution",
|
|
45
|
+
correlationStatus: "complete",
|
|
46
|
+
timeline: [],
|
|
47
|
+
messages: [],
|
|
48
|
+
runs: [],
|
|
49
|
+
workflows: [],
|
|
50
|
+
missing: [],
|
|
51
|
+
};
|
|
52
|
+
function setup() {
|
|
53
|
+
let state!: ReturnType<typeof useAgentWorkbench>;
|
|
54
|
+
const wrapper = mount(
|
|
55
|
+
defineComponent({
|
|
56
|
+
setup() {
|
|
57
|
+
state = useAgentWorkbench();
|
|
58
|
+
return () => null;
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
return { state, wrapper };
|
|
63
|
+
}
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
fingerprint.value = "actor-one";
|
|
66
|
+
vi.clearAllMocks();
|
|
67
|
+
mocks.list.mockResolvedValue({ items: [] });
|
|
68
|
+
mocks.operations.mockResolvedValue([]);
|
|
69
|
+
mocks.detail.mockResolvedValue(history);
|
|
70
|
+
mocks.session.mockResolvedValue({ sessionId: "session_test" });
|
|
71
|
+
mocks.run.mockResolvedValue({ runId: "run_test", status: "accepted" });
|
|
72
|
+
mocks.result.mockResolvedValue({
|
|
73
|
+
status: "completed",
|
|
74
|
+
output: { total: 12 },
|
|
75
|
+
});
|
|
76
|
+
mocks.cancel.mockResolvedValue({});
|
|
77
|
+
mocks.stream.mockImplementation(
|
|
78
|
+
async (_run, _operation, _session, onEvent) => {
|
|
79
|
+
onEvent({ sequence: 1, type: "run.completed", summary: "已完成" });
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
describe("真实测试工作台编排", () => {
|
|
84
|
+
test("选中的历史 canonical Session 只读,另起运行使用新 Session", async () => {
|
|
85
|
+
const { state, wrapper } = setup();
|
|
86
|
+
await flushPromises();
|
|
87
|
+
await state.select({
|
|
88
|
+
source: "harness",
|
|
89
|
+
sessionId: "session_historical",
|
|
90
|
+
title: "历史",
|
|
91
|
+
updatedAt: "today",
|
|
92
|
+
});
|
|
93
|
+
expect(mocks.run).not.toHaveBeenCalled();
|
|
94
|
+
await state.start("finance.query", {});
|
|
95
|
+
await flushPromises();
|
|
96
|
+
expect(mocks.session).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(mocks.run.mock.calls[0]?.[2]).toBe("session_test");
|
|
98
|
+
wrapper.unmount();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("使用常规治理API,成功结果后同一Session可以再运行,不重复建Session", async () => {
|
|
102
|
+
const { state, wrapper } = setup();
|
|
103
|
+
await flushPromises();
|
|
104
|
+
expect(await state.start("finance.query", { request: "sum" })).toBe(true);
|
|
105
|
+
await flushPromises();
|
|
106
|
+
expect(state.output.value).toEqual({ total: 12 });
|
|
107
|
+
expect(await state.start("finance.query", { request: "next" })).toBe(true);
|
|
108
|
+
await flushPromises();
|
|
109
|
+
expect(mocks.session).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(mocks.run).toHaveBeenCalledTimes(2);
|
|
111
|
+
expect(mocks.run.mock.calls[1]?.slice(0, 3)).toEqual([
|
|
112
|
+
"finance.query",
|
|
113
|
+
{ request: "next" },
|
|
114
|
+
"session_test",
|
|
115
|
+
]);
|
|
116
|
+
wrapper.unmount();
|
|
117
|
+
});
|
|
118
|
+
test("只读业务选择不会启动模型;另起测试使用独立Session", async () => {
|
|
119
|
+
const { state, wrapper } = setup();
|
|
120
|
+
await flushPromises();
|
|
121
|
+
await state.select({
|
|
122
|
+
source: "business",
|
|
123
|
+
sessionId: "copilot_one",
|
|
124
|
+
title: "业务",
|
|
125
|
+
updatedAt: "today",
|
|
126
|
+
});
|
|
127
|
+
expect(mocks.run).not.toHaveBeenCalled();
|
|
128
|
+
expect(mocks.stream).not.toHaveBeenCalled();
|
|
129
|
+
expect(mocks.result).not.toHaveBeenCalled();
|
|
130
|
+
await state.start("finance.query", {});
|
|
131
|
+
await flushPromises();
|
|
132
|
+
expect(mocks.run.mock.calls[0]?.[2]).toBe("session_test");
|
|
133
|
+
wrapper.unmount();
|
|
134
|
+
});
|
|
135
|
+
test("手动取消运行与离开detach分开,授权变化会隐藏旧记录", async () => {
|
|
136
|
+
mocks.stream.mockImplementation(() => new Promise(() => {}));
|
|
137
|
+
const { state, wrapper } = setup();
|
|
138
|
+
await flushPromises();
|
|
139
|
+
await state.start("finance.query", {});
|
|
140
|
+
await state.cancel();
|
|
141
|
+
expect(mocks.cancel.mock.calls[0]?.slice(0, 3)).toEqual([
|
|
142
|
+
"run_test",
|
|
143
|
+
"finance.query",
|
|
144
|
+
"session_test",
|
|
145
|
+
]);
|
|
146
|
+
fingerprint.value = "actor-two";
|
|
147
|
+
await flushPromises();
|
|
148
|
+
expect(state.liveRun.value).toBeUndefined();
|
|
149
|
+
expect(state.recording.value).toBeUndefined();
|
|
150
|
+
expect(state.sessions.value).toEqual([]);
|
|
151
|
+
wrapper.unmount();
|
|
152
|
+
expect(mocks.cancel).toHaveBeenCalledTimes(1);
|
|
153
|
+
});
|
|
154
|
+
test("新建失败保留错误且不自动重试", async () => {
|
|
155
|
+
mocks.run.mockRejectedValue(new Error("Runtime不可用"));
|
|
156
|
+
const { state, wrapper } = setup();
|
|
157
|
+
await flushPromises();
|
|
158
|
+
expect(await state.start("finance.query", {})).toBe(false);
|
|
159
|
+
await flushPromises();
|
|
160
|
+
expect(state.error.value).toContain("Runtime不可用");
|
|
161
|
+
expect(mocks.run).toHaveBeenCalledTimes(1);
|
|
162
|
+
wrapper.unmount();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { computed, onBeforeUnmount, shallowRef, watch, type Ref } from "vue";
|
|
2
|
+
import type { AgentWorkbenchEventV1 } from "@southwind-ai/shared";
|
|
3
|
+
/** Local playback changes only the visible cursor; it never calls a runtime. */
|
|
4
|
+
export function useSessionReplay(
|
|
5
|
+
events: Readonly<Ref<readonly AgentWorkbenchEventV1[]>>,
|
|
6
|
+
) {
|
|
7
|
+
const cursor = shallowRef(-1);
|
|
8
|
+
const playing = shallowRef(false);
|
|
9
|
+
const speed = shallowRef(1);
|
|
10
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
11
|
+
const current = computed(() => events.value[cursor.value]);
|
|
12
|
+
const visible = computed(() => events.value.slice(0, cursor.value + 1));
|
|
13
|
+
function pause() {
|
|
14
|
+
playing.value = false;
|
|
15
|
+
if (timer) clearTimeout(timer);
|
|
16
|
+
timer = undefined;
|
|
17
|
+
}
|
|
18
|
+
function seek(index: number) {
|
|
19
|
+
pause();
|
|
20
|
+
cursor.value = Math.max(-1, Math.min(index, events.value.length - 1));
|
|
21
|
+
}
|
|
22
|
+
function advance() {
|
|
23
|
+
if (!playing.value) return;
|
|
24
|
+
if (cursor.value >= events.value.length - 1) {
|
|
25
|
+
pause();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
cursor.value += 1;
|
|
29
|
+
timer = setTimeout(advance, 1000 / speed.value);
|
|
30
|
+
}
|
|
31
|
+
function play() {
|
|
32
|
+
if (cursor.value >= events.value.length - 1) cursor.value = -1;
|
|
33
|
+
playing.value = true;
|
|
34
|
+
advance();
|
|
35
|
+
}
|
|
36
|
+
watch(events, () => {
|
|
37
|
+
pause();
|
|
38
|
+
cursor.value = events.value.length ? 0 : -1;
|
|
39
|
+
});
|
|
40
|
+
watch(speed, () => {
|
|
41
|
+
if (playing.value) {
|
|
42
|
+
pause();
|
|
43
|
+
play();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
onBeforeUnmount(pause);
|
|
47
|
+
return { cursor, playing, speed, current, visible, seek, pause, play };
|
|
48
|
+
}
|
|
@@ -20,6 +20,8 @@ import SystemOperationsPage from "@/pages/system/SystemOperationsPage.vue";
|
|
|
20
20
|
// @windy-module system.operations end
|
|
21
21
|
// @windy-module system.agent begin
|
|
22
22
|
import AiRuntimePage from "@/pages/system/AiRuntimePage.vue";
|
|
23
|
+
import AgentToolsPage from "@/pages/system/AgentToolsPage.vue";
|
|
24
|
+
import AgentDebugPage from "@/pages/system/AgentDebugPage.vue";
|
|
23
25
|
import AgentSessionPage from "@/pages/system/AgentSessionPage.vue";
|
|
24
26
|
import AgentWorkflowPage from "@/pages/system/AgentWorkflowPage.vue";
|
|
25
27
|
// @windy-module system.agent end
|
|
@@ -101,6 +103,8 @@ const platformAdminPageComponents: Partial<
|
|
|
101
103
|
operations: SystemOperationsPage,
|
|
102
104
|
// @windy-module system.operations end
|
|
103
105
|
// @windy-module system.agent begin
|
|
106
|
+
"agent-tools": AgentToolsPage,
|
|
107
|
+
"agent-debug": AgentDebugPage,
|
|
104
108
|
"agent-session": AgentSessionPage,
|
|
105
109
|
"agent-workflow": AgentWorkflowPage,
|
|
106
110
|
"ai-runtime": AiRuntimePage,
|
|
@@ -8,6 +8,8 @@ export type PlatformAdminPageKey =
|
|
|
8
8
|
| "notification"
|
|
9
9
|
| "operations"
|
|
10
10
|
// @windy-module system.agent begin
|
|
11
|
+
| "agent-tools"
|
|
12
|
+
| "agent-debug"
|
|
11
13
|
| "agent-session"
|
|
12
14
|
| "agent-workflow"
|
|
13
15
|
| "ai-runtime"
|
|
@@ -60,6 +62,8 @@ export const platformAdminRouteBindings: readonly PlatformAdminRouteBinding[] =
|
|
|
60
62
|
{ routeKey: "system.ops", page: "operations" },
|
|
61
63
|
// @windy-module system.operations end
|
|
62
64
|
// @windy-module system.agent begin
|
|
65
|
+
{ routeKey: "system.agent-tools", page: "agent-tools" },
|
|
66
|
+
{ routeKey: "system.agent-debug", page: "agent-debug" },
|
|
63
67
|
{ routeKey: "system.agent-session", page: "agent-session" },
|
|
64
68
|
{ routeKey: "system.agent-workflow", page: "agent-workflow" },
|
|
65
69
|
{ routeKey: "system.ai-runtime", page: "ai-runtime" },
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentAdminOperationCatalogItem,
|
|
3
|
+
AgentAdminToolCatalogItem,
|
|
4
|
+
AgentWorkbenchRecordingSummaryV1,
|
|
5
|
+
AgentWorkbenchRecordingV1,
|
|
6
|
+
AgentWorkbenchEventV1,
|
|
7
|
+
JsonValue,
|
|
8
|
+
} from "@southwind-ai/shared";
|
|
9
|
+
import { apiRequest, apiResponse } from "./http";
|
|
10
|
+
import { publicWorkbenchEvent } from "./agent-workbench-event";
|
|
11
|
+
|
|
12
|
+
const policy = { suppressFailureHandler: true };
|
|
13
|
+
export function listAdminTools(signal?: AbortSignal) {
|
|
14
|
+
return apiRequest<readonly AgentAdminToolCatalogItem[]>(
|
|
15
|
+
"/agent/admin/tools",
|
|
16
|
+
{ signal },
|
|
17
|
+
policy,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
export function listAdminOperations(signal?: AbortSignal) {
|
|
21
|
+
return apiRequest<readonly AgentAdminOperationCatalogItem[]>(
|
|
22
|
+
"/agent/admin/operations",
|
|
23
|
+
{ signal },
|
|
24
|
+
policy,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
export function listWorkbenchRecordings(
|
|
28
|
+
source: "harness" | "business",
|
|
29
|
+
signal?: AbortSignal,
|
|
30
|
+
) {
|
|
31
|
+
return apiRequest<{ items: readonly AgentWorkbenchRecordingSummaryV1[] }>(
|
|
32
|
+
`/agent/admin/session-recordings?source=${source}`,
|
|
33
|
+
{ signal },
|
|
34
|
+
policy,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
export function getWorkbenchRecording(
|
|
38
|
+
sessionId: string,
|
|
39
|
+
source: "harness" | "business",
|
|
40
|
+
signal?: AbortSignal,
|
|
41
|
+
) {
|
|
42
|
+
return apiRequest<AgentWorkbenchRecordingV1>(
|
|
43
|
+
`/agent/admin/session-recordings/${encodeURIComponent(sessionId)}?source=${source}`,
|
|
44
|
+
{ signal },
|
|
45
|
+
policy,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
export function createTestSession(signal?: AbortSignal) {
|
|
49
|
+
return apiRequest<{ sessionId: string }>(
|
|
50
|
+
"/agent/admin/test-sessions",
|
|
51
|
+
{ method: "POST", body: "{}", signal },
|
|
52
|
+
policy,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
export function createTestRun(
|
|
56
|
+
operationKey: string,
|
|
57
|
+
input: JsonValue,
|
|
58
|
+
sessionId: string,
|
|
59
|
+
signal?: AbortSignal,
|
|
60
|
+
) {
|
|
61
|
+
return apiRequest<{ runId: string; status: string }>(
|
|
62
|
+
"/agent/runs",
|
|
63
|
+
{
|
|
64
|
+
method: "POST",
|
|
65
|
+
body: JSON.stringify({ operationKey, input, sessionId }),
|
|
66
|
+
headers: { "idempotency-key": crypto.randomUUID() },
|
|
67
|
+
signal,
|
|
68
|
+
},
|
|
69
|
+
policy,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
function runPath(
|
|
73
|
+
runId: string,
|
|
74
|
+
operationKey: string,
|
|
75
|
+
action: string,
|
|
76
|
+
sessionId: string,
|
|
77
|
+
) {
|
|
78
|
+
return `/agent/runs/${encodeURIComponent(runId)}/${action}?${new URLSearchParams({ operationKey, sessionId })}`;
|
|
79
|
+
}
|
|
80
|
+
export function getTestRunResult(
|
|
81
|
+
runId: string,
|
|
82
|
+
operationKey: string,
|
|
83
|
+
sessionId: string,
|
|
84
|
+
signal?: AbortSignal,
|
|
85
|
+
) {
|
|
86
|
+
return apiRequest<{ status: string; output?: JsonValue }>(
|
|
87
|
+
runPath(runId, operationKey, "result", sessionId),
|
|
88
|
+
{ signal },
|
|
89
|
+
policy,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
export function cancelTestRun(
|
|
93
|
+
runId: string,
|
|
94
|
+
operationKey: string,
|
|
95
|
+
sessionId: string,
|
|
96
|
+
signal?: AbortSignal,
|
|
97
|
+
) {
|
|
98
|
+
return apiRequest<unknown>(
|
|
99
|
+
runPath(runId, operationKey, "cancel", sessionId),
|
|
100
|
+
{
|
|
101
|
+
method: "POST",
|
|
102
|
+
body: JSON.stringify({ operationKey, sessionId }),
|
|
103
|
+
signal,
|
|
104
|
+
},
|
|
105
|
+
policy,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
/** The server publishes a controlled summary; never retain arbitrary provider payloads. */
|
|
109
|
+
export async function streamTestRun(
|
|
110
|
+
runId: string,
|
|
111
|
+
operationKey: string,
|
|
112
|
+
sessionId: string,
|
|
113
|
+
onEvent: (event: AgentWorkbenchEventV1) => void,
|
|
114
|
+
signal: AbortSignal,
|
|
115
|
+
) {
|
|
116
|
+
const response = await apiResponse(
|
|
117
|
+
runPath(runId, operationKey, "events", sessionId),
|
|
118
|
+
{ signal, headers: { accept: "text/event-stream" } },
|
|
119
|
+
policy,
|
|
120
|
+
);
|
|
121
|
+
if (!response.body) throw new Error("运行事件流不可用,请手动刷新会话");
|
|
122
|
+
const reader = response.body.getReader();
|
|
123
|
+
const decoder = new TextDecoder();
|
|
124
|
+
let buffer = "";
|
|
125
|
+
try {
|
|
126
|
+
while (!signal.aborted) {
|
|
127
|
+
const chunk = await reader.read();
|
|
128
|
+
if (chunk.done) break;
|
|
129
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
130
|
+
buffer = buffer.replace(/\r\n/g, "\n");
|
|
131
|
+
let boundary = buffer.indexOf("\n\n");
|
|
132
|
+
while (boundary !== -1) {
|
|
133
|
+
if (boundary > 1_048_576)
|
|
134
|
+
throw new Error("事件流超出安全大小,请手动刷新");
|
|
135
|
+
const frame = buffer.slice(0, boundary);
|
|
136
|
+
buffer = buffer.slice(boundary + 2);
|
|
137
|
+
const data = frame
|
|
138
|
+
.split("\n")
|
|
139
|
+
.filter((line) => line.startsWith("data:"))
|
|
140
|
+
.map((line) => line.slice(5).trimStart())
|
|
141
|
+
.join("\n");
|
|
142
|
+
if (data) {
|
|
143
|
+
const event = publicWorkbenchEvent(JSON.parse(data));
|
|
144
|
+
if (event) onEvent(event);
|
|
145
|
+
}
|
|
146
|
+
boundary = buffer.indexOf("\n\n");
|
|
147
|
+
}
|
|
148
|
+
if (buffer.length > 1_048_576)
|
|
149
|
+
throw new Error("事件流超出安全大小,请手动刷新");
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
await reader.cancel().catch(() => undefined);
|
|
153
|
+
reader.releaseLock();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
const mocks = vi.hoisted(() => ({ request: vi.fn(), response: vi.fn() }));
|
|
3
|
+
vi.mock("./http", () => ({
|
|
4
|
+
apiRequest: mocks.request,
|
|
5
|
+
apiResponse: mocks.response,
|
|
6
|
+
}));
|
|
7
|
+
import {
|
|
8
|
+
cancelTestRun,
|
|
9
|
+
createTestRun,
|
|
10
|
+
createTestSession,
|
|
11
|
+
getTestRunResult,
|
|
12
|
+
getWorkbenchRecording,
|
|
13
|
+
streamTestRun,
|
|
14
|
+
} from "./agent-admin-api";
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.clearAllMocks();
|
|
17
|
+
mocks.request.mockResolvedValue({});
|
|
18
|
+
});
|
|
19
|
+
describe("工作台正常治理 API 契约", () => {
|
|
20
|
+
test("创建真实Session/run并绑定幂等键,result/cancel带同一Session与Operation", async () => {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
await createTestSession(controller.signal);
|
|
23
|
+
await createTestRun(
|
|
24
|
+
"finance.query",
|
|
25
|
+
{ question: "sum" },
|
|
26
|
+
"session_one",
|
|
27
|
+
controller.signal,
|
|
28
|
+
);
|
|
29
|
+
expect(mocks.request.mock.calls[0]?.[0]).toBe("/agent/admin/test-sessions");
|
|
30
|
+
const request = mocks.request.mock.calls[1]!;
|
|
31
|
+
expect(request[0]).toBe("/agent/runs");
|
|
32
|
+
expect(JSON.parse(request[1].body)).toEqual({
|
|
33
|
+
operationKey: "finance.query",
|
|
34
|
+
input: { question: "sum" },
|
|
35
|
+
sessionId: "session_one",
|
|
36
|
+
});
|
|
37
|
+
expect(request[1].headers["idempotency-key"]).toMatch(/^[0-9a-f-]{36}$/);
|
|
38
|
+
expect(request[1].signal).toBe(controller.signal);
|
|
39
|
+
await getTestRunResult(
|
|
40
|
+
"run_one",
|
|
41
|
+
"finance.query",
|
|
42
|
+
"session_one",
|
|
43
|
+
controller.signal,
|
|
44
|
+
);
|
|
45
|
+
await cancelTestRun(
|
|
46
|
+
"run_one",
|
|
47
|
+
"finance.query",
|
|
48
|
+
"session_one",
|
|
49
|
+
controller.signal,
|
|
50
|
+
);
|
|
51
|
+
expect(mocks.request.mock.calls[2]?.[0]).toBe(
|
|
52
|
+
"/agent/runs/run_one/result?operationKey=finance.query&sessionId=session_one",
|
|
53
|
+
);
|
|
54
|
+
expect(mocks.request.mock.calls[3]?.[0]).toContain(
|
|
55
|
+
"/cancel?operationKey=finance.query&sessionId=session_one",
|
|
56
|
+
);
|
|
57
|
+
expect(mocks.request.mock.calls[3]?.[1].signal).toBe(controller.signal);
|
|
58
|
+
});
|
|
59
|
+
test("历史读取只请求受控recording,不触发result或启动接口", async () => {
|
|
60
|
+
await getWorkbenchRecording("business/session", "business");
|
|
61
|
+
expect(mocks.request).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(mocks.request.mock.calls[0]?.[0]).toBe(
|
|
63
|
+
"/agent/admin/session-recordings/business%2Fsession?source=business",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
test("SSE跨chunk CRLF与UTF8、同chunk多frame不会漏事件或保留私有参数", async () => {
|
|
67
|
+
const encoder = new TextEncoder();
|
|
68
|
+
const frames = [
|
|
69
|
+
'data: {"sequence":1,"type":"tool.requested","summary":"位置工具","arguments":{"token":"hidden"}}\r\n\r\n',
|
|
70
|
+
'data: {"sequence":2,"type":"run.completed","summary":"已完成"}\r\n\r\n',
|
|
71
|
+
].join("");
|
|
72
|
+
const bytes = encoder.encode(frames);
|
|
73
|
+
const split =
|
|
74
|
+
frames.indexOf("\r\n") +
|
|
75
|
+
encoder.encode("位置工具").length -
|
|
76
|
+
"位置工具".length +
|
|
77
|
+
1;
|
|
78
|
+
mocks.response.mockResolvedValue(
|
|
79
|
+
new Response(
|
|
80
|
+
new ReadableStream({
|
|
81
|
+
start(controller) {
|
|
82
|
+
controller.enqueue(bytes.slice(0, split));
|
|
83
|
+
controller.enqueue(bytes.slice(split));
|
|
84
|
+
controller.close();
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
const events: unknown[] = [];
|
|
90
|
+
await streamTestRun(
|
|
91
|
+
"run_1",
|
|
92
|
+
"query",
|
|
93
|
+
"session_1",
|
|
94
|
+
(event) => events.push(event),
|
|
95
|
+
new AbortController().signal,
|
|
96
|
+
);
|
|
97
|
+
expect(events).toHaveLength(2);
|
|
98
|
+
expect(JSON.stringify(events)).toContain("位置工具");
|
|
99
|
+
expect(JSON.stringify(events)).not.toContain("hidden");
|
|
100
|
+
});
|
|
101
|
+
test("单chunk的多个合法frame总大小超过1MB仍逐条处理", async () => {
|
|
102
|
+
const data = Array.from(
|
|
103
|
+
{ length: 100 },
|
|
104
|
+
(_, index) =>
|
|
105
|
+
`data: ${JSON.stringify({ sequence: index + 1, type: "output.delta", summary: "a".repeat(11000) })}\n\n`,
|
|
106
|
+
).join("");
|
|
107
|
+
mocks.response.mockResolvedValue(new Response(data));
|
|
108
|
+
let count = 0;
|
|
109
|
+
await streamTestRun(
|
|
110
|
+
"run_1",
|
|
111
|
+
"query",
|
|
112
|
+
"session_1",
|
|
113
|
+
() => {
|
|
114
|
+
count += 1;
|
|
115
|
+
},
|
|
116
|
+
new AbortController().signal,
|
|
117
|
+
);
|
|
118
|
+
expect(count).toBe(100);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AgentWorkbenchEventV1 } from "@southwind-ai/shared";
|
|
2
|
+
/** Defense in depth: discard fields outside the server's public event contract. */
|
|
3
|
+
export function publicWorkbenchEvent(
|
|
4
|
+
value: unknown,
|
|
5
|
+
): AgentWorkbenchEventV1 | undefined {
|
|
6
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
7
|
+
const raw = value as Record<string, unknown>;
|
|
8
|
+
if (
|
|
9
|
+
!Number.isSafeInteger(raw.sequence) ||
|
|
10
|
+
Number(raw.sequence) < 0 ||
|
|
11
|
+
typeof raw.type !== "string"
|
|
12
|
+
)
|
|
13
|
+
return;
|
|
14
|
+
const summary = typeof raw.summary === "string" ? raw.summary : raw.type;
|
|
15
|
+
const strings = [
|
|
16
|
+
"occurredAt",
|
|
17
|
+
"runId",
|
|
18
|
+
"turnId",
|
|
19
|
+
"stepId",
|
|
20
|
+
"modelAttemptId",
|
|
21
|
+
"toolCallId",
|
|
22
|
+
"toolKey",
|
|
23
|
+
"workItemId",
|
|
24
|
+
"artifactId",
|
|
25
|
+
"workflowRunId",
|
|
26
|
+
"nodeRunId",
|
|
27
|
+
"outcome",
|
|
28
|
+
] as const;
|
|
29
|
+
const refs = Object.fromEntries(
|
|
30
|
+
strings.flatMap((key) =>
|
|
31
|
+
typeof raw[key] === "string" ? [[key, raw[key]]] : [],
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
const usage =
|
|
35
|
+
raw.usage && typeof raw.usage === "object" && !Array.isArray(raw.usage)
|
|
36
|
+
? (raw.usage as Record<string, unknown>)
|
|
37
|
+
: undefined;
|
|
38
|
+
const measured = usage
|
|
39
|
+
? Object.fromEntries(
|
|
40
|
+
["inputTokens", "outputTokens", "totalTokens", "costMicros"].flatMap(
|
|
41
|
+
(key) =>
|
|
42
|
+
typeof usage[key] === "number" &&
|
|
43
|
+
Number.isFinite(usage[key]) &&
|
|
44
|
+
Number(usage[key]) >= 0
|
|
45
|
+
? [[key, usage[key]]]
|
|
46
|
+
: [],
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
: undefined;
|
|
50
|
+
return {
|
|
51
|
+
sequence: Number(raw.sequence),
|
|
52
|
+
type: raw.type,
|
|
53
|
+
summary,
|
|
54
|
+
...refs,
|
|
55
|
+
...(Number.isSafeInteger(raw.attemptSequence)
|
|
56
|
+
? { attemptSequence: Number(raw.attemptSequence) }
|
|
57
|
+
: {}),
|
|
58
|
+
...(measured ? { usage: measured } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -462,3 +462,7 @@ Compose、`service_adk_debug` 或活跃 `ADK_DEBUG_*` 配置。
|
|
|
462
462
|
- [DeepSeek Harness Architecture](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.md)
|
|
463
463
|
- [Vue Flow](https://vueflow.dev/):Vue 3 Workflow 画布候选;采用前仍需完成依赖评估与固定版本。
|
|
464
464
|
- [PageAgent](https://github.com/alibaba/page-agent):页面理解与浏览器动作复用边界。
|
|
465
|
+
|
|
466
|
+
## 管理端调试与业务回放(2026-10-07)
|
|
467
|
+
|
|
468
|
+
动态工具目录与统一调试工作台沿用本 Harness 治理边界;业务会话可提供真实 Run/Workflow 引用,父子 Session 回放沿用 AH-1 checkpoint/lineage。历史只读取已有记录,缺少引用或超出有界窗口时显示 partial/missing。测试运行新建独立 Session,不在历史回放中启动模型或工具。实现与发布边界见[工作台技术实现](./agent-admin-workbench-implementation.md),本节不将尚未发布的隔离执行与 Trace 扩展视为已交付。
|
|
@@ -57,6 +57,15 @@ function validateOperation(
|
|
|
57
57
|
operation: ModuleAiOperationDefinition,
|
|
58
58
|
catalogs: AiOperationCatalogs,
|
|
59
59
|
): void {
|
|
60
|
+
if (
|
|
61
|
+
operation.visibility !== undefined &&
|
|
62
|
+
operation.visibility !== "public" &&
|
|
63
|
+
operation.visibility !== "internal"
|
|
64
|
+
) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`AI Operation ${operation.key} visibility 必须为 public 或 internal`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
60
69
|
const strings = [
|
|
61
70
|
operation.key,
|
|
62
71
|
operation.featureKey,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ModuleAiOperationDefinition } from "./manifest.js";
|
|
3
|
+
import { validateModuleComposition } from "./composition.js";
|
|
4
|
+
import { fixture, operation } from "./ai-operation-composition.test-support.js";
|
|
5
|
+
|
|
6
|
+
describe("AI Operation browser visibility", () => {
|
|
7
|
+
test.each([undefined, "public", "internal"] as const)(
|
|
8
|
+
"Explicit %s keeps canonical registration compatible",
|
|
9
|
+
(visibility) => {
|
|
10
|
+
const module = fixture("business");
|
|
11
|
+
module.aiOperations = [
|
|
12
|
+
{
|
|
13
|
+
...operation("business.generate"),
|
|
14
|
+
...(visibility ? { visibility } : {}),
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
expect(() => validateModuleComposition([module])).not.toThrow();
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
test("Unknown visibility fails closed instead of becoming public", () => {
|
|
22
|
+
const module = fixture("business");
|
|
23
|
+
module.aiOperations = [
|
|
24
|
+
{
|
|
25
|
+
...operation("business.generate"),
|
|
26
|
+
visibility: "private",
|
|
27
|
+
} as unknown as ModuleAiOperationDefinition,
|
|
28
|
+
];
|
|
29
|
+
expect(() => validateModuleComposition([module])).toThrow(
|
|
30
|
+
"visibility 必须为 public 或 internal",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -234,6 +234,8 @@ export interface AiScreenshotContextBindingV1 {
|
|
|
234
234
|
export interface ModuleAiOperationDefinition {
|
|
235
235
|
key: string;
|
|
236
236
|
kind: AiOperationKind;
|
|
237
|
+
/** internal Operation 仅供可信服务端编排,不暴露普通 Agent Run API。 */
|
|
238
|
+
visibility?: "public" | "internal";
|
|
237
239
|
featureKey: string;
|
|
238
240
|
permissionKey: string;
|
|
239
241
|
auditAction: AuditActionType;
|
|
@@ -122,6 +122,22 @@ export const systemAdminRoutes: ModuleManifest["adminRoutes"] = [
|
|
|
122
122
|
),
|
|
123
123
|
// @windy-module system.audit end
|
|
124
124
|
// @windy-module system.agent begin
|
|
125
|
+
route(
|
|
126
|
+
"system.agent-tools",
|
|
127
|
+
"/admin/system/agent-tools",
|
|
128
|
+
"Agent 工具目录",
|
|
129
|
+
"sidebar",
|
|
130
|
+
"system.agent-tools.read",
|
|
131
|
+
"system.agent-tools",
|
|
132
|
+
),
|
|
133
|
+
route(
|
|
134
|
+
"system.agent-debug",
|
|
135
|
+
"/admin/system/agent-debug",
|
|
136
|
+
"Agent 调试工作台",
|
|
137
|
+
"sidebar",
|
|
138
|
+
"system.agent-debug.read",
|
|
139
|
+
"system.agent-debug",
|
|
140
|
+
),
|
|
125
141
|
route(
|
|
126
142
|
"system.agent-session",
|
|
127
143
|
"/admin/system/agent-sessions",
|