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.
Files changed (95) hide show
  1. package/dist/cli.js +52 -34
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/agent-server/src/southwind_agent_server/attempt_context.py +9 -1
  5. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +5 -2
  6. package/template/apps/agent-server/src/southwind_agent_server/provider_output_guidance.py +34 -0
  7. package/template/apps/agent-server/src/southwind_agent_server/structured_logging.py +16 -5
  8. package/template/apps/agent-server/tests/test_structured_logging.py +37 -0
  9. package/template/apps/agent-server/tests/test_tool_structured_output.py +347 -0
  10. package/template/apps/server/src/agent/admin-catalog-bootstrap.ts +25 -0
  11. package/template/apps/server/src/agent/agent-session-bootstrap.ts +7 -8
  12. package/template/apps/server/src/agent/agent-session-facade.ts +12 -0
  13. package/template/apps/server/src/agent/agent-session-validation.ts +43 -0
  14. package/template/apps/server/src/agent/operation-catalog.test.ts +202 -0
  15. package/template/apps/server/src/agent/operation-catalog.ts +135 -0
  16. package/template/apps/server/src/agent/run-browser-visibility.test.ts +59 -0
  17. package/template/apps/server/src/agent/run-browser-visibility.ts +11 -0
  18. package/template/apps/server/src/agent/run-operation-policy.ts +1 -0
  19. package/template/apps/server/src/agent/run-route-response.ts +35 -0
  20. package/template/apps/server/src/agent/run-routes.ts +46 -43
  21. package/template/apps/server/src/agent/runtime-bootstrap.ts +21 -20
  22. package/template/apps/server/src/agent/runtime-disabled-composition.ts +28 -0
  23. package/template/apps/server/src/agent/session-projection-facade.ts +8 -9
  24. package/template/apps/server/src/agent/session-projection-governance.ts +10 -0
  25. package/template/apps/server/src/agent/session-projection-view.ts +19 -16
  26. package/template/apps/server/src/agent/session-recording-reader.contract.test.ts +131 -0
  27. package/template/apps/server/src/agent/session-recording-reader.ts +62 -0
  28. package/template/apps/server/src/agent/tool-catalog-audit.ts +22 -0
  29. package/template/apps/server/src/agent/tool-catalog-availability.ts +21 -0
  30. package/template/apps/server/src/agent/tool-catalog-routes.test.ts +103 -0
  31. package/template/apps/server/src/agent/tool-catalog-routes.ts +62 -0
  32. package/template/apps/server/src/agent/tool-catalog.test.ts +122 -0
  33. package/template/apps/server/src/agent/tool-catalog.ts +44 -0
  34. package/template/apps/server/src/agent/tool-registry.ts +45 -14
  35. package/template/apps/server/src/agent/workbench-bootstrap.ts +39 -0
  36. package/template/apps/server/src/agent/workbench-event-projection.ts +115 -0
  37. package/template/apps/server/src/agent/workbench-facade.contract.test.ts +189 -0
  38. package/template/apps/server/src/agent/workbench-facade.ts +339 -0
  39. package/template/apps/server/src/agent/workbench-recording.contract.test.ts +328 -0
  40. package/template/apps/server/src/agent/workbench-routes.ts +97 -0
  41. package/template/apps/server/src/agent/workbench-run-integration.test.ts +231 -0
  42. package/template/apps/server/src/agent/workbench-run-reader.ts +187 -0
  43. package/template/apps/server/src/agent/workbench-safe-result.ts +61 -0
  44. package/template/apps/server/src/agent/workbench-sse.ts +50 -0
  45. package/template/apps/server/src/agent-workflow/query-service.ts +36 -0
  46. package/template/apps/server/src/agent-workflow/recording-projection.contract.test.ts +196 -0
  47. package/template/apps/server/src/agent-workflow/recording-projection.ts +86 -0
  48. package/template/apps/web/src/pages/system/AgentDebugPage.vue +14 -0
  49. package/template/apps/web/src/pages/system/AgentSessionPage.vue +29 -8
  50. package/template/apps/web/src/pages/system/AgentToolsPage.vue +76 -0
  51. package/template/apps/web/src/pages/system/components/agent-workbench/AgentWorkbench.vue +236 -0
  52. package/template/apps/web/src/pages/system/components/agent-workbench/DraftLeaveDialog.vue +34 -0
  53. package/template/apps/web/src/pages/system/components/agent-workbench/ReplayEventDetail.vue +175 -0
  54. package/template/apps/web/src/pages/system/components/agent-workbench/SessionReplayTimeline.vue +142 -0
  55. package/template/apps/web/src/pages/system/components/agent-workbench/SessionReplayTimeline.webtest.ts +242 -0
  56. package/template/apps/web/src/pages/system/components/agent-workbench/TestRunComposer.vue +97 -0
  57. package/template/apps/web/src/pages/system/components/agent-workbench/ToolCatalogPanel.vue +100 -0
  58. package/template/apps/web/src/pages/system/components/agent-workbench/ToolCatalogPanel.webtest.ts +55 -0
  59. package/template/apps/web/src/pages/system/components/agent-workbench/ToolRuleDetail.vue +81 -0
  60. package/template/apps/web/src/pages/system/components/agent-workbench/WorkbenchSessionList.vue +57 -0
  61. package/template/apps/web/src/pages/system/components/agent-workbench/catalog-presenter.ts +20 -0
  62. package/template/apps/web/src/pages/system/components/agent-workbench/replay-presenter.ts +94 -0
  63. package/template/apps/web/src/pages/system/composables/useAgentWorkbench.ts +298 -0
  64. package/template/apps/web/src/pages/system/composables/useAgentWorkbench.webtest.ts +164 -0
  65. package/template/apps/web/src/pages/system/composables/useSessionReplay.ts +48 -0
  66. package/template/apps/web/src/router/index.ts +4 -0
  67. package/template/apps/web/src/router/platform-admin-route-bindings.ts +4 -0
  68. package/template/apps/web/src/services/agent-admin-api.ts +155 -0
  69. package/template/apps/web/src/services/agent-admin-api.webtest.ts +120 -0
  70. package/template/apps/web/src/services/agent-workbench-event.ts +60 -0
  71. package/template/docs/architecture/agent-harness.md +4 -0
  72. package/template/docs/architecture/log-governance-implementation.md +20 -3
  73. package/template/docs/architecture/log-governance-plan.md +2 -2
  74. package/template/docs/platform/agent-runtime.md +18 -0
  75. package/template/packages/modules/src/ai-operation-composition.ts +9 -0
  76. package/template/packages/modules/src/ai-operation-visibility.test.ts +33 -0
  77. package/template/packages/modules/src/manifest.ts +2 -0
  78. package/template/packages/modules/src/system-admin-routes.ts +16 -0
  79. package/template/packages/modules/src/system-admin-ui-catalog.ts +7 -1
  80. package/template/packages/modules/src/system-agent-admin-bindings.ts +42 -0
  81. package/template/packages/modules/src/system-agent-admin-menus.ts +26 -0
  82. package/template/packages/modules/src/system-agent-admin-registration.test.ts +52 -0
  83. package/template/packages/modules/src/system-agent-audit-actions.ts +5 -0
  84. package/template/packages/modules/src/system-ai-api-bindings.ts +2 -0
  85. package/template/packages/modules/src/system-audit-actions.ts +25 -0
  86. package/template/packages/modules/src/system-features.ts +19 -0
  87. package/template/packages/modules/src/system-module-catalog.ts +14 -12
  88. package/template/packages/modules/src/system-module-ownership.ts +12 -0
  89. package/template/packages/modules/src/system-modules.ts +2 -0
  90. package/template/packages/modules/src/system-permissions.ts +5 -0
  91. package/template/packages/server-sdk/index.ts +2 -0
  92. package/template/packages/server-sdk/src/agent-session-provider.ts +19 -0
  93. package/template/packages/shared/index.ts +2 -0
  94. package/template/packages/shared/src/agent-admin-catalog.ts +73 -0
  95. package/template/packages/shared/src/agent-workbench.ts +100 -0
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import sqlite3
6
+ from dataclasses import replace
7
+ from pathlib import Path
8
+ from typing import cast
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ from southwind_agent_server.attempt_context import govern_provider_attempt
14
+ from southwind_agent_server.attempt_context_projection import (
15
+ AttemptContextPreparation,
16
+ governed_envelope,
17
+ )
18
+ from southwind_agent_server.context_message_digest import canonical_json_digest
19
+ from southwind_agent_server.context_version_port import (
20
+ PreparedContext,
21
+ PreparedSource,
22
+ UnavailableContextVersionPort,
23
+ )
24
+ from southwind_agent_server.contracts import JsonValue, ProviderFieldPolicy
25
+ from southwind_agent_server.engine import (
26
+ EngineCompleted,
27
+ EngineRunRequest,
28
+ SafeEngineError,
29
+ ToolCompleted,
30
+ )
31
+ from southwind_agent_server.openai_engine import OpenAiCompatibleEngineAdapter
32
+ from southwind_agent_server.tool_host import ToolHostClient
33
+ from tests.openai_provider_test_support import (
34
+ create_provider,
35
+ governance,
36
+ runtime_request,
37
+ sse_response,
38
+ )
39
+
40
+ SCHEMA: dict[str, JsonValue] = {
41
+ "type": "object",
42
+ "properties": {"total": {"type": "integer"}},
43
+ "required": ["total"],
44
+ "additionalProperties": False,
45
+ }
46
+
47
+
48
+ def structured_request() -> EngineRunRequest:
49
+ request = runtime_request("business.records.list")
50
+ return replace(
51
+ request,
52
+ output_schema_key="business.answer.v1",
53
+ output_schema=SCHEMA,
54
+ provider_data_policy=request.provider_data_policy.model_copy(
55
+ update={
56
+ "envelope_fields": [
57
+ *request.provider_data_policy.envelope_fields,
58
+ ProviderFieldPolicy(
59
+ field="output-schema",
60
+ classification="internal",
61
+ dlp_action="redact",
62
+ ),
63
+ ]
64
+ }
65
+ ),
66
+ )
67
+
68
+
69
+ def test_tool_payload_uses_schema_guidance_without_mutation_or_duplicate() -> None:
70
+ provider = create_provider(httpx.MockTransport(lambda _: httpx.Response(500)))
71
+ request = runtime_request("business.records.list")
72
+ messages: list[dict[str, JsonValue]] = [{"role": "system", "content": "原指令"}]
73
+ body = provider.request_body(messages, request.allowed_tools, "answer.v1", SCHEMA)
74
+ payload = json.loads(body)
75
+ assert "response_format" not in payload
76
+ assert payload["tools"][0]["function"]["name"] == "business.records.list"
77
+ assert payload["messages"][0]["content"].startswith("原指令\n\n")
78
+ assert (
79
+ json.dumps(SCHEMA, ensure_ascii=False, separators=(",", ":"))
80
+ in payload["messages"][0]["content"]
81
+ )
82
+ assert messages[0]["content"] == "原指令"
83
+ assert (
84
+ provider.request_body(
85
+ payload["messages"], request.allowed_tools, "answer.v1", SCHEMA
86
+ )
87
+ == body
88
+ )
89
+ plain = json.loads(provider.request_body(messages, (), "answer.v1", SCHEMA))
90
+ assert plain["messages"] == messages
91
+ assert plain["response_format"]["json_schema"]["strict"] is True
92
+
93
+
94
+ @pytest.mark.parametrize("final", ['{"total":1}', '{"total":"1"}', "```json\n{}\n```"])
95
+ async def test_real_function_round_keeps_strict_final_schema(final: str) -> None:
96
+ turns = 0
97
+ calls = 0
98
+
99
+ async def provider_handler(request: httpx.Request) -> httpx.Response:
100
+ nonlocal turns
101
+ turns += 1
102
+ body = json.loads(request.content)
103
+ assert "response_format" not in body
104
+ assert '"required":["total"]' in body["messages"][0]["content"]
105
+ if turns == 1:
106
+ return sse_response(
107
+ [
108
+ {
109
+ "choices": [
110
+ {
111
+ "delta": {
112
+ "tool_calls": [
113
+ {
114
+ "index": 0,
115
+ "id": "call_1",
116
+ "function": {
117
+ "name": "business.records.list",
118
+ "arguments": "{}",
119
+ },
120
+ }
121
+ ]
122
+ }
123
+ }
124
+ ]
125
+ }
126
+ ]
127
+ )
128
+ assert body["messages"][-1]["role"] == "tool"
129
+ return sse_response([{"choices": [{"delta": {"content": final}}]}])
130
+
131
+ async def tool_handler(request: httpx.Request) -> httpx.Response:
132
+ nonlocal calls
133
+ calls += 1
134
+ assert json.loads(request.content)["executionGrant"] == "grant_1"
135
+ return httpx.Response(200, json={"value": {"total": 1}})
136
+
137
+ engine = OpenAiCompatibleEngineAdapter(
138
+ create_provider(httpx.MockTransport(provider_handler)),
139
+ ToolHostClient("internal-token", transport=httpx.MockTransport(tool_handler)),
140
+ governance(),
141
+ )
142
+ request = structured_request()
143
+ if final == '{"total":1}':
144
+ signals = [signal async for signal in engine.execute(request)]
145
+ assert any(isinstance(signal, ToolCompleted) for signal in signals)
146
+ assert isinstance(signals[-1], EngineCompleted)
147
+ assert signals[-1].output == {"total": 1}
148
+ else:
149
+ with pytest.raises(SafeEngineError) as denied:
150
+ _ = [signal async for signal in engine.execute(request)]
151
+ assert denied.value.code == "OUTPUT_VALIDATION_FAILED"
152
+ assert turns == 2 and calls == 1
153
+
154
+
155
+ async def test_text_tool_call_is_not_executable() -> None:
156
+ async def provider_handler(_: httpx.Request) -> httpx.Response:
157
+ return sse_response(
158
+ [
159
+ {
160
+ "choices": [
161
+ {
162
+ "delta": {
163
+ "content": (
164
+ '{"tool_calls":[{"name":"business.records.list",'
165
+ '"arguments":{}}]}'
166
+ )
167
+ }
168
+ }
169
+ ]
170
+ }
171
+ ]
172
+ )
173
+
174
+ async def tool_handler(_: httpx.Request) -> httpx.Response:
175
+ pytest.fail("正文中的工具调用不得执行")
176
+
177
+ engine = OpenAiCompatibleEngineAdapter(
178
+ create_provider(httpx.MockTransport(provider_handler)),
179
+ ToolHostClient("internal-token", transport=httpx.MockTransport(tool_handler)),
180
+ governance(),
181
+ )
182
+ with pytest.raises(SafeEngineError) as denied:
183
+ _ = [signal async for signal in engine.execute(structured_request())]
184
+ assert denied.value.code == "OUTPUT_VALIDATION_FAILED"
185
+
186
+
187
+ async def test_governed_guidance_matches_audit_and_context_bytes(
188
+ tmp_path: Path,
189
+ ) -> None:
190
+ request = structured_request()
191
+ request = replace(
192
+ request, output_schema={**SCHEMA, "description": "password=secret"}
193
+ )
194
+ provider = create_provider(httpx.MockTransport(lambda _: httpx.Response(500)))
195
+ audit_path = str(tmp_path / "audit.sqlite3")
196
+ messages: list[dict[str, JsonValue]] = [
197
+ {"role": "system", "content": request.instructions},
198
+ {"role": "user", "content": "历史来源"},
199
+ {"role": "user", "content": request.user_prompt},
200
+ ]
201
+ prepared = await govern_provider_attempt(
202
+ UnavailableContextVersionPort(),
203
+ governance(audit_path),
204
+ provider,
205
+ request,
206
+ 1,
207
+ None,
208
+ messages,
209
+ )
210
+ payload = json.loads(prepared.provider_request)
211
+ assert payload["messages"] == prepared.governed.messages
212
+ assert "password=secret" not in prepared.provider_request.decode()
213
+ assert "[REDACTED]" in payload["messages"][0]["content"]
214
+ with sqlite3.connect(audit_path) as connection:
215
+ assert connection.execute(
216
+ "select sent_bytes, decision from provider_attempt_audit"
217
+ ).fetchone() == (len(prepared.provider_request), "redact")
218
+ source = PreparedSource(
219
+ "business", "session", "source-1", "v1", 1, "a" * 64, {}, "历史来源", "user"
220
+ )
221
+ envelope = governed_envelope(
222
+ AttemptContextPreparation(
223
+ PreparedContext("preparation-1", (source,)), messages, 1
224
+ ),
225
+ request.run_id,
226
+ "attempt-1",
227
+ prepared.governed.messages,
228
+ prepared.provider_request,
229
+ provider.config.deployment_key,
230
+ provider.config.model,
231
+ request.prompt_version,
232
+ (),
233
+ )
234
+ entries = cast(list[dict[str, JsonValue]], envelope["messages"])
235
+ assert entries[0]["content"] == payload["messages"][0]["content"]
236
+ assert (
237
+ cast(list[dict[str, JsonValue]], entries[1]["origins"])[0]["sourceId"]
238
+ == "source-1"
239
+ )
240
+ provider_fields = cast(dict[str, JsonValue], envelope["provider"])
241
+ assert (
242
+ provider_fields["requestDigest"]
243
+ == hashlib.sha256(prepared.provider_request).hexdigest()
244
+ )
245
+ assert provider_fields["outputSchemaDigest"] == hashlib.sha256(b"null").hexdigest()
246
+ assert entries[0]["digest"] == canonical_json_digest(payload["messages"][0])
247
+ assert entries[0]["digest"] != canonical_json_digest(cast(JsonValue, messages[0]))
248
+
249
+
250
+ async def test_schema_guidance_still_requires_output_schema_authorization() -> None:
251
+ request = structured_request()
252
+ request = replace(
253
+ request,
254
+ provider_data_policy=runtime_request(
255
+ "business.records.list"
256
+ ).provider_data_policy,
257
+ )
258
+ with pytest.raises(SafeEngineError) as denied:
259
+ await govern_provider_attempt(
260
+ UnavailableContextVersionPort(),
261
+ governance(),
262
+ create_provider(httpx.MockTransport(lambda _: httpx.Response(500))),
263
+ request,
264
+ 1,
265
+ None,
266
+ [{"role": "system", "content": "指令"}],
267
+ )
268
+ assert denied.value.code == "FORBIDDEN"
269
+
270
+
271
+ async def test_schema_guidance_counts_toward_input_token_budget() -> None:
272
+ calls = 0
273
+
274
+ async def handler(_: httpx.Request) -> httpx.Response:
275
+ nonlocal calls
276
+ calls += 1
277
+ return sse_response([])
278
+
279
+ provider = create_provider(httpx.MockTransport(handler))
280
+ request = structured_request()
281
+ messages: list[dict[str, JsonValue]] = [
282
+ {"role": "system", "content": request.instructions},
283
+ {"role": "user", "content": request.user_prompt},
284
+ ]
285
+ original_bytes = len(provider.request_body(messages, request.allowed_tools))
286
+ assert original_bytes < 1024
287
+ request = replace(
288
+ request,
289
+ output_schema={**SCHEMA, "description": "业务结果字段说明。" * 100},
290
+ limits=request.limits.model_copy(update={"max_input_tokens": original_bytes}),
291
+ )
292
+ engine = OpenAiCompatibleEngineAdapter(
293
+ provider, ToolHostClient("internal-token"), governance()
294
+ )
295
+ with pytest.raises(SafeEngineError) as denied:
296
+ _ = [signal async for signal in engine.execute(request)]
297
+ assert denied.value.code == "BUDGET_EXCEEDED"
298
+ assert calls == 0
299
+
300
+
301
+ async def test_tool_schema_respects_model_attempt_limit() -> None:
302
+ calls = 0
303
+
304
+ async def handler(_: httpx.Request) -> httpx.Response:
305
+ nonlocal calls
306
+ calls += 1
307
+ return sse_response(
308
+ [
309
+ {
310
+ "choices": [
311
+ {
312
+ "delta": {
313
+ "tool_calls": [
314
+ {
315
+ "index": 0,
316
+ "id": "call_1",
317
+ "function": {
318
+ "name": "business.records.list",
319
+ "arguments": "{}",
320
+ },
321
+ }
322
+ ]
323
+ }
324
+ }
325
+ ]
326
+ }
327
+ ]
328
+ )
329
+
330
+ request = structured_request()
331
+ request = replace(
332
+ request, limits=request.limits.model_copy(update={"max_model_attempts": 1})
333
+ )
334
+ engine = OpenAiCompatibleEngineAdapter(
335
+ create_provider(httpx.MockTransport(handler)),
336
+ ToolHostClient(
337
+ "internal-token",
338
+ transport=httpx.MockTransport(
339
+ lambda _: httpx.Response(200, json={"value": {"total": 1}})
340
+ ),
341
+ ),
342
+ governance(),
343
+ )
344
+ with pytest.raises(SafeEngineError) as denied:
345
+ _ = [signal async for signal in engine.execute(request)]
346
+ assert denied.value.code == "BUDGET_EXCEEDED"
347
+ assert calls == 1
@@ -0,0 +1,25 @@
1
+ import type { ModuleAiOperationReadinessPortV1 } from "@southwind-ai/server-sdk";
2
+ import type { AgentPlatformRuntimeInput } from "./runtime-bootstrap-input.js";
3
+ import type { composeAgentToolRuntime } from "./runtime-tool-composition.js";
4
+ import { registerAgentAdminCatalogRoutes } from "./tool-catalog-routes.js";
5
+
6
+ export interface AgentAdminReadinessRef {
7
+ current?: ModuleAiOperationReadinessPortV1;
8
+ }
9
+
10
+ export function registerAgentAdminCatalogRuntime(
11
+ input: AgentPlatformRuntimeInput,
12
+ tools: ReturnType<typeof composeAgentToolRuntime>,
13
+ readiness: AgentAdminReadinessRef,
14
+ ) {
15
+ registerAgentAdminCatalogRoutes(input.app, {
16
+ modules: input.modules,
17
+ hosts: input.hosts,
18
+ runtime: input.runtime,
19
+ registry: tools.toolRegistry,
20
+ authorizer: tools.routeAuthorizer,
21
+ runtimeAvailable: () => input.platformConfig.enableAI,
22
+ operationReadiness: async (operationKey) =>
23
+ (await readiness.current?.inspect({ operationKey }))?.status === "ready",
24
+ });
25
+ }
@@ -11,13 +11,12 @@ export function registerAgentSessions(input: {
11
11
  runtime: ReturnType<typeof createServerRuntime>;
12
12
  secret: string;
13
13
  }) {
14
- if (!input.hosts.agentSessionProviders.length) return;
15
- registerAgentSessionRoutesV1(
16
- input.app,
17
- new AgentSessionFacadeV1(
18
- input.hosts,
19
- input.runtime,
20
- new AgentSessionIdCodecV1(input.secret),
21
- ),
14
+ const facade = new AgentSessionFacadeV1(
15
+ input.hosts,
16
+ input.runtime,
17
+ new AgentSessionIdCodecV1(input.secret),
22
18
  );
19
+ if (!input.hosts.agentSessionProviders.length) return facade;
20
+ registerAgentSessionRoutesV1(input.app, facade);
21
+ return facade;
23
22
  }
@@ -2,6 +2,8 @@ import { randomUUIDv7 } from "bun";
2
2
  import {
3
3
  MODULE_AGENT_SESSION_SCHEMA_VERSION,
4
4
  type ModuleAgentSessionSummaryV1,
5
+ type ModuleAgentSessionRunReferenceV1,
6
+ type ModuleAgentSessionWorkflowReferenceV1,
5
7
  } from "@southwind-ai/server-sdk";
6
8
  import { evaluateAccessGuards } from "../access-guards.js";
7
9
  import { auditActor, type RequestGuardContext } from "../guards.js";
@@ -33,12 +35,15 @@ export interface AgentSessionSummaryDtoV1 {
33
35
  }
34
36
 
35
37
  export interface AgentSessionDetailDtoV1 extends AgentSessionSummaryDtoV1 {
38
+ readonly truncated?: boolean;
36
39
  readonly messages: readonly {
37
40
  sequence: number;
38
41
  role: "user" | "assistant";
39
42
  content: string;
40
43
  createdAt: string;
41
44
  }[];
45
+ readonly runRefs?: readonly ModuleAgentSessionRunReferenceV1[];
46
+ readonly workflowRefs?: readonly ModuleAgentSessionWorkflowReferenceV1[];
42
47
  }
43
48
 
44
49
  export class AgentSessionFacadeError extends Error {
@@ -156,6 +161,13 @@ export class AgentSessionFacadeV1 {
156
161
  session: {
157
162
  ...this.project(entry, actor.id, validated),
158
163
  messages: validated.messages,
164
+ ...(validated.truncated !== undefined
165
+ ? { truncated: validated.truncated }
166
+ : {}),
167
+ ...(validated.runRefs ? { runRefs: validated.runRefs } : {}),
168
+ ...(validated.workflowRefs
169
+ ? { workflowRefs: validated.workflowRefs }
170
+ : {}),
159
171
  } satisfies AgentSessionDetailDtoV1,
160
172
  };
161
173
  } catch (error) {
@@ -66,6 +66,49 @@ export function validateAgentSessionDetail(
66
66
  previousSequence = message.sequence;
67
67
  }
68
68
  if (totalChars > MAX_TOTAL_MESSAGE_CHARS) invalidProvider();
69
+ if (value.truncated !== undefined && typeof value.truncated !== "boolean")
70
+ invalidProvider();
71
+ const messageSequences = new Set(
72
+ value.messages.map(({ sequence }) => sequence),
73
+ );
74
+ if (value.runRefs !== undefined) {
75
+ if (!Array.isArray(value.runRefs) || value.runRefs.length > 200)
76
+ invalidProvider();
77
+ const ids = new Set<string>();
78
+ for (const ref of value.runRefs) {
79
+ if (
80
+ !ref ||
81
+ !validText(ref.runId, 200) ||
82
+ !validText(ref.operationKey, 200) ||
83
+ !/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(ref.operationKey) ||
84
+ ids.has(ref.runId) ||
85
+ (ref.sessionId !== undefined && !validText(ref.sessionId, 200)) ||
86
+ (ref.turnId !== undefined && !validText(ref.turnId, 200)) ||
87
+ (ref.workflowRunId !== undefined &&
88
+ !validText(ref.workflowRunId, 200)) ||
89
+ (ref.messageSequence !== undefined &&
90
+ !messageSequences.has(ref.messageSequence))
91
+ )
92
+ invalidProvider();
93
+ ids.add(ref.runId);
94
+ }
95
+ }
96
+ if (value.workflowRefs !== undefined) {
97
+ if (!Array.isArray(value.workflowRefs) || value.workflowRefs.length > 100)
98
+ invalidProvider();
99
+ const ids = new Set<string>();
100
+ for (const ref of value.workflowRefs) {
101
+ if (
102
+ !ref ||
103
+ !validText(ref.workflowRunId, 200) ||
104
+ ids.has(ref.workflowRunId) ||
105
+ (ref.messageSequence !== undefined &&
106
+ !messageSequences.has(ref.messageSequence))
107
+ )
108
+ invalidProvider();
109
+ ids.add(ref.workflowRunId);
110
+ }
111
+ }
69
112
  return value;
70
113
  }
71
114
 
@@ -0,0 +1,202 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createServerRuntime } from "../runtime.js";
3
+ import { AgentAdminOperationCatalog } from "./operation-catalog.js";
4
+ import {
5
+ createRunFacadeFixture,
6
+ runFacadeManifest,
7
+ } from "./run-facade.test-support.js";
8
+
9
+ async function fixture(
10
+ options: { workflowOnly?: boolean; runtimeAvailable?: boolean } = {},
11
+ ) {
12
+ const run = await createRunFacadeFixture(undefined, {
13
+ workflowOnly: options.workflowOnly,
14
+ });
15
+ const module = runFacadeManifest(options.workflowOnly);
16
+ const catalog = new AgentAdminOperationCatalog({
17
+ modules: [module],
18
+ hosts: run.hosts,
19
+ runtime: run.runtime,
20
+ runtimeAvailable: () => options.runtimeAvailable ?? true,
21
+ });
22
+ return { ...run, catalog, module };
23
+ }
24
+
25
+ describe("Admin Operation catalogue uses production registration", () => {
26
+ test("Registered and governed agent exposes real Schema/limits/tools, without preparing or running", async () => {
27
+ const { catalog, context, prepared, module, remote } = await fixture();
28
+ const [operation] = await catalog.list(context);
29
+ expect(operation).toMatchObject({
30
+ key: "business.answer",
31
+ module: "business",
32
+ inputSchemaKey: "business.input.v1",
33
+ inputSchema: module.jsonSchemas!.find(
34
+ ({ key }) => key === "business.input.v1",
35
+ )!.schema,
36
+ outputSchemaKey: "business.output.v1",
37
+ allowedToolKeys: ["business.records.list"],
38
+ limits: module.aiOperations![0]!.limits,
39
+ selectable: true,
40
+ availabilityReason: "allowed",
41
+ });
42
+ expect(prepared).toHaveLength(0);
43
+ expect(remote.started).toHaveLength(0);
44
+ });
45
+
46
+ test("Catalog read capability cannot start an unauthorized operation", async () => {
47
+ const { catalog, context, facade } = await fixture();
48
+ context.actor.permissionKeys = ["system.agent-debug.read"];
49
+ const [operation] = await catalog.list(context);
50
+ expect(operation).toMatchObject({
51
+ selectable: false,
52
+ availabilityReason: "permission-denied",
53
+ });
54
+ await expect(
55
+ facade.start(
56
+ "business.answer",
57
+ { question: "测试" },
58
+ "catalog-no-grant",
59
+ context,
60
+ ),
61
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
62
+ });
63
+
64
+ test("Workflow-only operation and unavailable Runtime are unselectable", async () => {
65
+ const workflow = await fixture({ workflowOnly: true });
66
+ expect((await workflow.catalog.list(workflow.context))[0]).toMatchObject({
67
+ selectable: false,
68
+ availabilityReason: "workflow-only",
69
+ launchAuthority: "workflow-artifact",
70
+ });
71
+ const unavailable = await fixture({ runtimeAvailable: false });
72
+ expect(
73
+ (await unavailable.catalog.list(unavailable.context))[0],
74
+ ).toMatchObject({
75
+ selectable: false,
76
+ availabilityReason: "runtime-unavailable",
77
+ });
78
+ });
79
+
80
+ test("Declared readiness needs a current successful projection", async () => {
81
+ const { module, hosts, runtime, context } = await fixture();
82
+ module.aiOperations![0]!.readiness = {
83
+ contractVersion: 1,
84
+ variant: "test",
85
+ inputModalities: ["text"],
86
+ outputModalities: ["text"],
87
+ expectedDeployment: {
88
+ key: "test",
89
+ identity: "test",
90
+ releaseDigest: `sha256:${"a".repeat(64)}`,
91
+ offersDigest: `sha256:${"b".repeat(64)}`,
92
+ },
93
+ minConcurrentRuns: 1,
94
+ minQueueDepth: 1,
95
+ };
96
+ const input = {
97
+ modules: [module],
98
+ hosts,
99
+ runtime,
100
+ runtimeAvailable: () => true,
101
+ };
102
+ expect(
103
+ (await new AgentAdminOperationCatalog(input).list(context))[0],
104
+ ).toMatchObject({
105
+ selectable: false,
106
+ availabilityReason: "runtime-unavailable",
107
+ });
108
+ expect(
109
+ (
110
+ await new AgentAdminOperationCatalog({
111
+ ...input,
112
+ operationReadiness: async () => false,
113
+ }).list(context)
114
+ )[0],
115
+ ).toMatchObject({
116
+ selectable: false,
117
+ availabilityReason: "runtime-unavailable",
118
+ });
119
+ expect(
120
+ (
121
+ await new AgentAdminOperationCatalog({
122
+ ...input,
123
+ operationReadiness: async () => true,
124
+ }).list(context)
125
+ )[0],
126
+ ).toMatchObject({ selectable: true });
127
+ });
128
+
129
+ test("Unregistered handler, unsupported kind, invalid scope fail closed", async () => {
130
+ const { module, hosts, runtime, context } = await fixture();
131
+ const catalog = (current = module, currentHosts = hosts) =>
132
+ new AgentAdminOperationCatalog({
133
+ modules: [current],
134
+ hosts: currentHosts,
135
+ runtime,
136
+ runtimeAvailable: () => true,
137
+ });
138
+ expect(
139
+ (
140
+ await catalog(module, { ...hosts, aiOperationHandlers: [] }).list(
141
+ context,
142
+ )
143
+ )[0],
144
+ ).toMatchObject({
145
+ selectable: false,
146
+ availabilityReason: "handler-unavailable",
147
+ });
148
+ const speech = structuredClone(module);
149
+ speech.aiOperations![0]!.kind = "speech";
150
+ expect((await catalog(speech).list(context))[0]).toMatchObject({
151
+ selectable: false,
152
+ availabilityReason: "unsupported-operation",
153
+ });
154
+ delete context.actor.scopeContext;
155
+ expect((await catalog().list(context))[0]).toMatchObject({
156
+ selectable: false,
157
+ availabilityReason: "scope-unavailable",
158
+ });
159
+ });
160
+
161
+ test("Feature closure and missing registered output Schema fail closed", async () => {
162
+ const { module, hosts, runtime, context } = await fixture();
163
+ const missingSchema = structuredClone(module);
164
+ missingSchema.jsonSchemas = missingSchema.jsonSchemas!.filter(
165
+ ({ key }) => key !== "business.output.v1",
166
+ );
167
+ expect(
168
+ () =>
169
+ new AgentAdminOperationCatalog({
170
+ modules: [missingSchema],
171
+ hosts,
172
+ runtime,
173
+ runtimeAvailable: () => true,
174
+ }),
175
+ ).toThrow();
176
+ const disabledModule = structuredClone(module);
177
+ disabledModule.features = disabledModule.features.map((feature) => ({
178
+ ...feature,
179
+ enabled: false,
180
+ }));
181
+ const disabledRuntime = createServerRuntime(
182
+ {},
183
+ { featureCatalog: disabledModule.features },
184
+ );
185
+ const missing = new AgentAdminOperationCatalog({
186
+ modules: [disabledModule],
187
+ hosts,
188
+ runtime: disabledRuntime,
189
+ runtimeAvailable: () => true,
190
+ });
191
+ expect((await missing.list(context))[0]).toMatchObject({
192
+ selectable: false,
193
+ availabilityReason: "feature-unavailable",
194
+ });
195
+ context.license = { status: "expired", restricted: true };
196
+ context.licenseEnforced = true;
197
+ expect((await missing.list(context))[0]).toMatchObject({
198
+ selectable: false,
199
+ availabilityReason: "license-unavailable",
200
+ });
201
+ });
202
+ });