diff --git a/.flocks/flockshub/index.json b/.flocks/flockshub/index.json
index 4d3560e97..3baac2e34 100644
--- a/.flocks/flockshub/index.json
+++ b/.flocks/flockshub/index.json
@@ -14640,7 +14640,7 @@
"name": "SOC Workspace WebUI",
"description": "SOC workspace pages for posture, overview, and alert investigation.",
"descriptionCn": "SOC 工作区页面,包含态势、SOC 总览和告警调查。",
- "version": "1.1.5",
+ "version": "1.1.6",
"category": "workflow-automation",
"tags": [
"siem",
@@ -14746,6 +14746,86 @@
"trust": "official",
"riskLevel": "medium",
"manifestPath": "plugins/components/soc-workspace/manifest.json"
+ },
+ {
+ "id": "code_audit_ui",
+ "type": "webui",
+ "name": "Code Audit Workspace WebUI",
+ "nameCn": "代码审计工作区页面",
+ "description": "Code audit workspace pages for audit overview and findings.",
+ "descriptionCn": "代码审计工作区页面,包含审计总览和缺陷清单。",
+ "version": "1.0.0",
+ "category": "workflow-automation",
+ "tags": [
+ "integration"
+ ],
+ "useCases": [
+ "security-reporting"
+ ],
+ "trust": "official",
+ "riskLevel": "low",
+ "edition": "pro",
+ "manifestPath": "plugins/webuis/code_audit_ui/manifest.json"
+ },
+ {
+ "id": "code-audit-workspace",
+ "type": "component",
+ "name": "Code Audit Workspace Component",
+ "nameCn": "代码审计场景套件",
+ "description": "Composite installer for the Flocks code audit workspace.",
+ "descriptionCn": "Flocks 代码审计工作区场景套件安装器。",
+ "version": "1.0.0",
+ "category": "workflow-automation",
+ "tags": [
+ "integration"
+ ],
+ "useCases": [
+ "security-reporting"
+ ],
+ "trust": "official",
+ "riskLevel": "low",
+ "edition": "pro",
+ "manifestPath": "plugins/components/code-audit-workspace/manifest.json"
+ },
+ {
+ "id": "ai_redteam_ui",
+ "type": "webui",
+ "name": "AI Redteam Workspace WebUI",
+ "nameCn": "AI 红队工作区页面",
+ "description": "AI redteam workspace pages for campaigns and attack surface.",
+ "descriptionCn": "AI 红队工作区页面,包含演练总览和攻击面。",
+ "version": "1.0.0",
+ "category": "workflow-automation",
+ "tags": [
+ "integration"
+ ],
+ "useCases": [
+ "security-reporting"
+ ],
+ "trust": "official",
+ "riskLevel": "low",
+ "edition": "pro",
+ "manifestPath": "plugins/webuis/ai_redteam_ui/manifest.json"
+ },
+ {
+ "id": "ai-redteam-workspace",
+ "type": "component",
+ "name": "AI Redteam Workspace Component",
+ "nameCn": "AI 红队场景套件",
+ "description": "Composite installer for the Flocks AI redteam workspace.",
+ "descriptionCn": "Flocks AI 红队工作区场景套件安装器。",
+ "version": "1.0.0",
+ "category": "workflow-automation",
+ "tags": [
+ "integration"
+ ],
+ "useCases": [
+ "security-reporting"
+ ],
+ "trust": "official",
+ "riskLevel": "low",
+ "edition": "pro",
+ "manifestPath": "plugins/components/ai-redteam-workspace/manifest.json"
}
]
}
diff --git a/.flocks/flockshub/plugins/components/ai-redteam-workspace/component.json b/.flocks/flockshub/plugins/components/ai-redteam-workspace/component.json
new file mode 100644
index 000000000..72c3a7342
--- /dev/null
+++ b/.flocks/flockshub/plugins/components/ai-redteam-workspace/component.json
@@ -0,0 +1,14 @@
+{
+ "schemaVersion": "hub.component.v1",
+ "id": "ai-redteam-workspace",
+ "name": "AI Redteam Workspace",
+ "nameCn": "AI 红队工作区",
+ "description": "Composite package for the Flocks AI redteam workspace WebUI.",
+ "descriptionCn": "Flocks AI 红队工作区组件包,包含 WebUI 页面。",
+ "components": [
+ {
+ "type": "webui",
+ "id": "ai_redteam_ui"
+ }
+ ]
+}
diff --git a/.flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json b/.flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json
new file mode 100644
index 000000000..6dbb294e3
--- /dev/null
+++ b/.flocks/flockshub/plugins/components/ai-redteam-workspace/manifest.json
@@ -0,0 +1,66 @@
+{
+ "schemaVersion": "hub.plugin.v1",
+ "id": "ai-redteam-workspace",
+ "type": "component",
+ "name": "AI Redteam Workspace Component",
+ "nameCn": "AI 红队场景套件",
+ "description": "Composite installer for the Flocks AI redteam workspace.",
+ "descriptionCn": "Flocks AI 红队工作区场景套件安装器。",
+ "version": "1.0.0",
+ "author": "Flocks Team",
+ "license": "MIT",
+ "homepage": "",
+ "category": "workflow-automation",
+ "tags": [
+ "integration"
+ ],
+ "useCases": [
+ "security-reporting"
+ ],
+ "domains": [
+ "security-ops"
+ ],
+ "capabilities": [
+ "workflow"
+ ],
+ "trust": "official",
+ "edition": "pro",
+ "source": {
+ "kind": "bundled",
+ "path": "plugins/components/ai-redteam-workspace"
+ },
+ "compatibility": {
+ "flocks": ">=0.8.0",
+ "os": [
+ "darwin",
+ "linux",
+ "windows"
+ ]
+ },
+ "dependencies": {
+ "skills": [],
+ "tools": [],
+ "python": [],
+ "external": []
+ },
+ "permissions": {
+ "tools": [],
+ "network": false,
+ "shell": false,
+ "filesystem": "read"
+ },
+ "risk": {
+ "level": "low",
+ "reasons": []
+ },
+ "entrypoints": [
+ "component.json"
+ ],
+ "components": [
+ {
+ "type": "webui",
+ "id": "ai_redteam_ui"
+ }
+ ],
+ "checksums": {}
+}
diff --git a/.flocks/flockshub/plugins/components/code-audit-workspace/component.json b/.flocks/flockshub/plugins/components/code-audit-workspace/component.json
new file mode 100644
index 000000000..4d5da5021
--- /dev/null
+++ b/.flocks/flockshub/plugins/components/code-audit-workspace/component.json
@@ -0,0 +1,14 @@
+{
+ "schemaVersion": "hub.component.v1",
+ "id": "code-audit-workspace",
+ "name": "Code Audit Workspace",
+ "nameCn": "代码审计工作区",
+ "description": "Composite package for the Flocks code audit workspace WebUI.",
+ "descriptionCn": "Flocks 代码审计工作区组件包,包含 WebUI 页面。",
+ "components": [
+ {
+ "type": "webui",
+ "id": "code_audit_ui"
+ }
+ ]
+}
diff --git a/.flocks/flockshub/plugins/components/code-audit-workspace/manifest.json b/.flocks/flockshub/plugins/components/code-audit-workspace/manifest.json
new file mode 100644
index 000000000..89886bab4
--- /dev/null
+++ b/.flocks/flockshub/plugins/components/code-audit-workspace/manifest.json
@@ -0,0 +1,66 @@
+{
+ "schemaVersion": "hub.plugin.v1",
+ "id": "code-audit-workspace",
+ "type": "component",
+ "name": "Code Audit Workspace Component",
+ "nameCn": "代码审计场景套件",
+ "description": "Composite installer for the Flocks code audit workspace.",
+ "descriptionCn": "Flocks 代码审计工作区场景套件安装器。",
+ "version": "1.0.0",
+ "author": "Flocks Team",
+ "license": "MIT",
+ "homepage": "",
+ "category": "workflow-automation",
+ "tags": [
+ "integration"
+ ],
+ "useCases": [
+ "security-reporting"
+ ],
+ "domains": [
+ "security-ops"
+ ],
+ "capabilities": [
+ "workflow"
+ ],
+ "trust": "official",
+ "edition": "pro",
+ "source": {
+ "kind": "bundled",
+ "path": "plugins/components/code-audit-workspace"
+ },
+ "compatibility": {
+ "flocks": ">=0.8.0",
+ "os": [
+ "darwin",
+ "linux",
+ "windows"
+ ]
+ },
+ "dependencies": {
+ "skills": [],
+ "tools": [],
+ "python": [],
+ "external": []
+ },
+ "permissions": {
+ "tools": [],
+ "network": false,
+ "shell": false,
+ "filesystem": "read"
+ },
+ "risk": {
+ "level": "low",
+ "reasons": []
+ },
+ "entrypoints": [
+ "component.json"
+ ],
+ "components": [
+ {
+ "type": "webui",
+ "id": "code_audit_ui"
+ }
+ ],
+ "checksums": {}
+}
diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json
new file mode 100644
index 000000000..777ed3f52
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/manifest.json
@@ -0,0 +1,12 @@
+{
+ "id": "ai-redteam-overview",
+ "title": "演练总览",
+ "titleEn": "Campaign Overview",
+ "route": "/contracts/webui/ai-redteam-overview",
+ "icon": "Crosshair",
+ "order": 10,
+ "enabled": true,
+ "placement": "home.after",
+ "entry": "src/index.tsx",
+ "updatedAt": 1788900000000
+}
diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx
new file mode 100644
index 000000000..4a867e5a0
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/Page.tsx
@@ -0,0 +1,14 @@
+import { Card } from '@flocks/webui-contract-sdk';
+
+export default function Page() {
+ return (
+
+ AI 红队场景的入口:编排演练、跟踪每一轮的结论。
+
+ - 选择目标系统与授权范围,创建一次演练
+ - 演练过程由红队 Agent 驱动,每一步都留下可复核的记录
+ - 演练结束后生成结论,可直接转成整改任务
+
+
+ );
+}
diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx
new file mode 100644
index 000000000..12c3418bb
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_overview/src/index.tsx
@@ -0,0 +1,3 @@
+import Page from './Page';
+
+export default Page;
diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json
new file mode 100644
index 000000000..0979aec6d
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/manifest.json
@@ -0,0 +1,12 @@
+{
+ "id": "ai-redteam-surface",
+ "title": "攻击面",
+ "titleEn": "Attack Surface",
+ "route": "/contracts/webui/ai-redteam-surface",
+ "icon": "Radar",
+ "order": 20,
+ "enabled": true,
+ "placement": "home.after",
+ "entry": "src/index.tsx",
+ "updatedAt": 1788900000000
+}
diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx
new file mode 100644
index 000000000..a93aa94b2
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/Page.tsx
@@ -0,0 +1,14 @@
+import { Card } from '@flocks/webui-contract-sdk';
+
+export default function Page() {
+ return (
+
+ 汇总目标系统暴露出的攻击面与已验证的利用路径。
+
+ - 按资产、入口、权限层级组织攻击面
+ - 每条利用路径关联到验证它的那一次演练
+ - 已修复的路径保留历史,便于回归验证
+
+
+ );
+}
diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx
new file mode 100644
index 000000000..12c3418bb
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/ai_redteam_surface/src/index.tsx
@@ -0,0 +1,3 @@
+import Page from './Page';
+
+export default Page;
diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json
new file mode 100644
index 000000000..eb4358c3f
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/manifest.json
@@ -0,0 +1,61 @@
+{
+ "schemaVersion": "hub.plugin.v1",
+ "id": "ai_redteam_ui",
+ "type": "webui",
+ "name": "AI Redteam Workspace WebUI",
+ "nameCn": "AI 红队工作区页面",
+ "description": "AI redteam workspace pages for campaigns and attack surface.",
+ "descriptionCn": "AI 红队工作区页面,包含演练总览和攻击面。",
+ "version": "1.0.0",
+ "author": "Flocks Team",
+ "license": "MIT",
+ "homepage": "",
+ "category": "workflow-automation",
+ "tags": [
+ "integration"
+ ],
+ "useCases": [
+ "security-reporting"
+ ],
+ "domains": [
+ "security-ops"
+ ],
+ "capabilities": [
+ "workflow"
+ ],
+ "trust": "official",
+ "edition": "pro",
+ "source": {
+ "kind": "bundled",
+ "path": "plugins/webuis/ai_redteam_ui"
+ },
+ "compatibility": {
+ "flocks": ">=0.8.0",
+ "os": [
+ "darwin",
+ "linux",
+ "windows"
+ ]
+ },
+ "dependencies": {
+ "skills": [],
+ "tools": [],
+ "python": [],
+ "external": []
+ },
+ "permissions": {
+ "tools": [],
+ "network": false,
+ "shell": false,
+ "filesystem": "read"
+ },
+ "risk": {
+ "level": "low",
+ "reasons": []
+ },
+ "entrypoints": [
+ "workspace.json"
+ ],
+ "components": [],
+ "checksums": {}
+}
diff --git a/.flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json
new file mode 100644
index 000000000..161447dd0
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/ai_redteam_ui/workspace.json
@@ -0,0 +1,11 @@
+{
+ "id": "ai_redteam_ui",
+ "version": "1.0.0",
+ "title": "AI 红队",
+ "titleEn": "AI Redteam",
+ "icon": "Crosshair",
+ "order": 30,
+ "enabled": true,
+ "placement": "sceneWorkspace",
+ "defaultPageId": "ai-redteam-overview"
+}
diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json
new file mode 100644
index 000000000..1d3a2cd1b
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/manifest.json
@@ -0,0 +1,12 @@
+{
+ "id": "code-audit-findings",
+ "title": "缺陷清单",
+ "titleEn": "Findings",
+ "route": "/contracts/webui/code-audit-findings",
+ "icon": "ListChecks",
+ "order": 20,
+ "enabled": true,
+ "placement": "home.after",
+ "entry": "src/index.tsx",
+ "updatedAt": 1788900000000
+}
diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx
new file mode 100644
index 000000000..0de7204b2
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/Page.tsx
@@ -0,0 +1,14 @@
+import { Card } from '@flocks/webui-contract-sdk';
+
+export default function Page() {
+ return (
+
+ 按严重级别列出代码审计发现的问题,支持指派与复核。
+
+ - 按严重级别、文件、规则筛选
+ - 每条缺陷保留触发它的代码位置与判定依据
+ - 复核通过后标记为已处理,下次审计不再重复报告
+
+
+ );
+}
diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx
new file mode 100644
index 000000000..12c3418bb
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_findings/src/index.tsx
@@ -0,0 +1,3 @@
+import Page from './Page';
+
+export default Page;
diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json
new file mode 100644
index 000000000..7d64ec965
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/manifest.json
@@ -0,0 +1,12 @@
+{
+ "id": "code-audit-overview",
+ "title": "审计总览",
+ "titleEn": "Audit Overview",
+ "route": "/contracts/webui/code-audit-overview",
+ "icon": "FileSearch",
+ "order": 10,
+ "enabled": true,
+ "placement": "home.after",
+ "entry": "src/index.tsx",
+ "updatedAt": 1788900000000
+}
diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx
new file mode 100644
index 000000000..62d6b9e99
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/Page.tsx
@@ -0,0 +1,14 @@
+import { Card } from '@flocks/webui-contract-sdk';
+
+export default function Page() {
+ return (
+
+ 代码审计场景的入口:接入仓库、发起审计、跟踪修复进度。
+
+ - 在 Agent 分区的工作台里用代码审计 Agent 发起一次审计
+ - 审计结果按仓库、分支、提交聚合,进入缺陷清单查看
+ - 缺陷修复后重新审计,对比上一次的结论
+
+
+ );
+}
diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx
new file mode 100644
index 000000000..12c3418bb
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/code_audit_overview/src/index.tsx
@@ -0,0 +1,3 @@
+import Page from './Page';
+
+export default Page;
diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json
new file mode 100644
index 000000000..70b899324
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/manifest.json
@@ -0,0 +1,61 @@
+{
+ "schemaVersion": "hub.plugin.v1",
+ "id": "code_audit_ui",
+ "type": "webui",
+ "name": "Code Audit Workspace WebUI",
+ "nameCn": "代码审计工作区页面",
+ "description": "Code audit workspace pages for audit overview and findings.",
+ "descriptionCn": "代码审计工作区页面,包含审计总览和缺陷清单。",
+ "version": "1.0.0",
+ "author": "Flocks Team",
+ "license": "MIT",
+ "homepage": "",
+ "category": "workflow-automation",
+ "tags": [
+ "integration"
+ ],
+ "useCases": [
+ "security-reporting"
+ ],
+ "domains": [
+ "security-ops"
+ ],
+ "capabilities": [
+ "workflow"
+ ],
+ "trust": "official",
+ "edition": "pro",
+ "source": {
+ "kind": "bundled",
+ "path": "plugins/webuis/code_audit_ui"
+ },
+ "compatibility": {
+ "flocks": ">=0.8.0",
+ "os": [
+ "darwin",
+ "linux",
+ "windows"
+ ]
+ },
+ "dependencies": {
+ "skills": [],
+ "tools": [],
+ "python": [],
+ "external": []
+ },
+ "permissions": {
+ "tools": [],
+ "network": false,
+ "shell": false,
+ "filesystem": "read"
+ },
+ "risk": {
+ "level": "low",
+ "reasons": []
+ },
+ "entrypoints": [
+ "workspace.json"
+ ],
+ "components": [],
+ "checksums": {}
+}
diff --git a/.flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json b/.flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json
new file mode 100644
index 000000000..01ac6d16b
--- /dev/null
+++ b/.flocks/flockshub/plugins/webuis/code_audit_ui/workspace.json
@@ -0,0 +1,11 @@
+{
+ "id": "code_audit_ui",
+ "version": "1.0.0",
+ "title": "代码审计",
+ "titleEn": "Code Audit",
+ "icon": "FileSearch",
+ "order": 20,
+ "enabled": true,
+ "placement": "sceneWorkspace",
+ "defaultPageId": "code-audit-overview"
+}
diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json
index 25c7d5cdd..cfa3f5464 100644
--- a/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json
+++ b/.flocks/flockshub/plugins/webuis/soc_ui/manifest.json
@@ -5,7 +5,7 @@
"name": "SOC Workspace WebUI",
"description": "SOC workspace pages for posture, overview, and alert investigation.",
"descriptionCn": "SOC 工作区页面,包含态势、SOC 总览和告警调查。",
- "version": "1.1.5",
+ "version": "1.1.6",
"author": "Flocks Team",
"license": "MIT",
"homepage": "",
diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json
index afabbbcb0..ddab530e0 100644
--- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json
+++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/manifest.json
@@ -8,5 +8,5 @@
"enabled": true,
"placement": "home.after",
"entry": "src/index.tsx",
- "updatedAt": 1781509788949
+ "updatedAt": 1788857311724
}
diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx
index c08e3d499..9e0092c6a 100644
--- a/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx
+++ b/.flocks/flockshub/plugins/webuis/soc_ui/soc_dashboard/src/Page.tsx
@@ -2981,7 +2981,7 @@ export default function Page() {
const CSS = `
.adtd-root {
box-sizing: border-box;
- min-height: 100vh;
+ min-height: 100%;
min-width: 1100px;
margin: 0;
padding: 0 16px;
@@ -4296,7 +4296,7 @@ const CSS = `
--command-blue: #3677bc;
--command-border: rgba(255, 255, 255, .09);
position: relative;
- height: 100vh;
+ height: 100%;
min-height: 720px;
min-width: 1180px;
margin: 0;
@@ -4516,7 +4516,7 @@ const CSS = `
z-index: 2;
display: grid;
grid-template-columns: minmax(0, 1fr) var(--event-rail-width, 330px);
- height: calc(100vh - 68px);
+ height: calc(100% - 68px);
min-height: 652px;
transition: grid-template-columns .24s ease;
}
diff --git a/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json b/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json
index 3bf045c38..730c229bd 100644
--- a/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json
+++ b/.flocks/flockshub/plugins/webuis/soc_ui/workspace.json
@@ -1,6 +1,6 @@
{
"id": "soc_ui",
- "version": "1.1.5",
+ "version": "1.1.6",
"title": "SOC 工作区",
"titleEn": "SOC Workspace",
"icon": "ShieldCheck",
diff --git a/flocks/contracts/webui/store.py b/flocks/contracts/webui/store.py
index 7222ba9a9..ca33f41e1 100644
--- a/flocks/contracts/webui/store.py
+++ b/flocks/contracts/webui/store.py
@@ -27,6 +27,9 @@
WORKSPACE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_]*$")
MAX_SOURCE_FILE_BYTES = 512_000
ALLOWED_WRITE_PREFIXES = ("src/", "assets/", "api/")
+# Workspaces are disabled from the UI without touching the plugin files, so a
+# suite update never resurrects a workspace the operator turned off.
+WORKSPACE_STATE_FILE = ".workspace-state.json"
ALLOWED_WRITE_FILES = frozenset({"manifest.json"})
WORKSPACE_MANIFEST_FILE = "workspace.json"
_SOURCE_SUFFIXES = {".tsx", ".ts", ".jsx", ".js", ".css", ".json"}
@@ -199,10 +202,44 @@ def _assert_writable_relative(self, relative_path: str) -> Path:
return Path(rel)
raise ValueError(f"writes are not allowed for path: {relative_path}")
+ def _workspace_state_path(self) -> Path:
+ return self._root / WORKSPACE_STATE_FILE
+
+ def read_workspace_enabled_overrides(self) -> dict[str, bool]:
+ """Operator overrides of `enabled`, keyed by workspace id."""
+ try:
+ raw = self._workspace_state_path().read_text(encoding="utf-8")
+ except OSError:
+ return {}
+ try:
+ data = json.loads(raw)
+ except ValueError:
+ log.warning("webui.workspace_state.invalid", {"path": str(self._workspace_state_path())})
+ return {}
+ enabled = data.get("enabled") if isinstance(data, dict) else None
+ if not isinstance(enabled, dict):
+ return {}
+ return {str(key): bool(value) for key, value in enabled.items()}
+
+ def set_workspace_enabled(self, workspace_id: str, enabled: bool) -> WebUIWorkspaceListItem:
+ workspace_id = self.validate_workspace_id(workspace_id)
+ overrides = self.read_workspace_enabled_overrides()
+ overrides[workspace_id] = bool(enabled)
+ self.ensure_root()
+ self._workspace_state_path().write_text(
+ json.dumps({"enabled": overrides}, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+ for workspace in self.list_workspaces():
+ if workspace.id == workspace_id:
+ return workspace
+ raise FileNotFoundError(f"工作区不存在: {workspace_id}")
+
def list_pages(self, *, enabled_only: bool = False) -> list[WebUIPageListItem]:
self.ensure_root()
items: list[WebUIPageListItem] = []
seen_keys: set[str] = set()
+ overrides = self.read_workspace_enabled_overrides()
for root in self._read_roots:
if not root.is_dir():
continue
@@ -215,10 +252,15 @@ def list_pages(self, *, enabled_only: bool = False) -> list[WebUIPageListItem]:
if page_id in seen_keys or manifest.id in seen_keys:
continue
seen_keys.update({page_id, manifest.id})
- if enabled_only and not manifest.enabled:
+ workspace = self._workspace_for_page_dir(root, page_dir)
+ # A page of a disabled workspace is off too, so old links to it
+ # stop resolving until the workspace is enabled again.
+ page_enabled = manifest.enabled and (
+ overrides.get(workspace.id, True) if workspace else True
+ )
+ if enabled_only and not page_enabled:
continue
build = self._read_build_meta_at(page_dir)
- workspace = self._workspace_for_page_dir(root, page_dir)
items.append(
WebUIPageListItem(
id=manifest.id,
@@ -227,7 +269,7 @@ def list_pages(self, *, enabled_only: bool = False) -> list[WebUIPageListItem]:
route=manifest.route,
icon=manifest.icon,
order=manifest.order,
- enabled=manifest.enabled,
+ enabled=page_enabled,
placement=manifest.placement,
buildHash=build.hash,
buildStatus=build.status,
@@ -244,6 +286,7 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL
self.ensure_root()
workspaces: list[WebUIWorkspaceListItem] = []
seen_workspace_ids: set[str] = set()
+ overrides = self.read_workspace_enabled_overrides()
for root in self._read_roots:
if not root.is_dir():
continue
@@ -251,7 +294,8 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL
if manifest.id in seen_workspace_ids:
continue
seen_workspace_ids.add(manifest.id)
- if enabled_only and not manifest.enabled:
+ workspace_enabled = overrides.get(manifest.id, manifest.enabled)
+ if enabled_only and not workspace_enabled:
continue
pages: list[WebUIPageListItem] = []
@@ -263,7 +307,8 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL
if page_manifest is None:
continue
seen_page_ids.add(page_manifest.id)
- if enabled_only and not page_manifest.enabled:
+ page_enabled = page_manifest.enabled and workspace_enabled
+ if enabled_only and not page_enabled:
continue
build = self._read_build_meta_at(page_dir)
pages.append(
@@ -274,7 +319,7 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL
route=page_manifest.route,
icon=page_manifest.icon,
order=page_manifest.order,
- enabled=page_manifest.enabled,
+ enabled=page_enabled,
placement=page_manifest.placement,
buildHash=build.hash,
buildStatus=build.status,
@@ -294,7 +339,7 @@ def list_workspaces(self, *, enabled_only: bool = False) -> list[WebUIWorkspaceL
route=webui_contract_workspace_route(manifest.id),
icon=manifest.icon,
order=manifest.order,
- enabled=manifest.enabled,
+ enabled=workspace_enabled,
placement=manifest.placement,
defaultPageId=manifest.defaultPageId,
sections=manifest.sections,
diff --git a/flocks/hub/catalog.py b/flocks/hub/catalog.py
index a999a12b3..24b423960 100644
--- a/flocks/hub/catalog.py
+++ b/flocks/hub/catalog.py
@@ -807,6 +807,7 @@ def _entry_from_manifest(manifest: HubPluginManifest) -> HubCatalogEntry:
capabilities=manifest.capabilities,
trust=manifest.trust,
riskLevel=manifest.risk.level,
+ edition=getattr(manifest, "edition", "oss"),
state=state,
installedVersion=installed_version,
source=manifest.source.kind,
@@ -866,6 +867,7 @@ def _entry_from_index(
useCases=item.useCases,
trust=item.trust,
riskLevel=item.riskLevel,
+ edition=item.edition,
state=state,
installedVersion=installed_version,
source="bundled",
@@ -891,6 +893,7 @@ def _entry_from_system_manifest(manifest: HubPluginManifest, root: Path) -> HubC
capabilities=manifest.capabilities,
trust=manifest.trust,
riskLevel=manifest.risk.level,
+ edition=getattr(manifest, "edition", "oss"),
state="installed",
installedVersion=manifest.version,
source="system",
@@ -959,6 +962,7 @@ def _entry_from_bundled_tool(
capabilities=manifest.capabilities,
trust=manifest.trust,
riskLevel=manifest.risk.level,
+ edition=getattr(manifest, "edition", "oss"),
state=state,
installedVersion=installed_version,
source="bundled",
diff --git a/flocks/hub/installer.py b/flocks/hub/installer.py
index eb894126c..a08080ae3 100644
--- a/flocks/hub/installer.py
+++ b/flocks/hub/installer.py
@@ -11,7 +11,7 @@
from typing import Awaitable, Callable
from flocks.hub import local
-from flocks.hub.catalog import clear_catalog_caches, load_manifest
+from flocks.hub.catalog import _version_tuple, clear_catalog_caches, load_manifest
from flocks.hub.files import plugin_root
from flocks.hub.models import (
HubComponentRef,
@@ -464,6 +464,22 @@ def _can_adopt_existing_ref(
return record.source == _bundled_source_for_ref(ref)
+def _component_ref_is_outdated(
+ ref: HubComponentRef,
+ install_path: Path,
+ record: Optional[InstalledPluginRecord],
+) -> bool:
+ """True when the installed child is older than the version this suite ships."""
+ try:
+ available = load_manifest(ref.type, ref.id).version
+ except Exception:
+ return False
+ installed = record.version if record is not None else local.installed_payload_version(ref.type, install_path)
+ if not installed:
+ return False
+ return _version_tuple(installed) < _version_tuple(available)
+
+
async def _install_component_refs(
manifest: HubPluginManifest,
*,
@@ -492,6 +508,28 @@ async def _install_component_refs(
existing_path = local.infer_local_install(ref.type, ref.id)
if existing_path is not None:
existing_record = local.get_record(ref.type, ref.id)
+ # Installing or updating the suite has to bring an outdated
+ # child along, otherwise the suite reads as current while its
+ # pages stay on the old version.
+ if _component_ref_is_outdated(ref, existing_path, existing_record):
+ item.status = "installing"
+ await _emit_component_progress(progress, manifest, "item", item=item)
+ try:
+ await install_plugin(ref.type, ref.id, scope=scope, installed_by=component_key)
+ except Exception as exc:
+ if ref.optional:
+ item.status = "skipped"
+ item.message = f"Optional dependency failed to update: {exc}"
+ await _emit_component_progress(progress, manifest, "item", item=item)
+ continue
+ item.status = "failed"
+ item.message = str(exc) or "Update failed"
+ await _emit_component_progress(progress, manifest, "item", item=item)
+ raise
+ item.status = "installed"
+ item.message = "Updated by component"
+ await _emit_component_progress(progress, manifest, "item", item=item)
+ continue
if existing_record is not None and _can_adopt_existing_ref(ref, existing_record, component_key, existing_path):
adopted_records.append(existing_record)
local.save_installed_record(existing_record.model_copy(update={"installedBy": component_key}))
diff --git a/flocks/hub/models.py b/flocks/hub/models.py
index a9640d21a..4b24c2883 100644
--- a/flocks/hub/models.py
+++ b/flocks/hub/models.py
@@ -8,6 +8,10 @@
PluginType = Literal["skill", "agent", "tool", "device", "workflow", "webui", "component"]
+# Which edition may install a plugin. Pro-only suites stay visible in the
+# catalog so the operator can see what an upgrade unlocks.
+PluginEdition = Literal["oss", "pro"]
+
PluginState = Literal[
"available",
"installed",
@@ -75,6 +79,7 @@ class HubPluginManifest(BaseModel):
domains: list[str] = Field(default_factory=list)
capabilities: list[str] = Field(default_factory=list)
trust: Literal["official", "verified", "community", "experimental", "deprecated"] = "community"
+ edition: PluginEdition = "oss"
source: HubSource = Field(default_factory=HubSource)
compatibility: HubCompatibility = Field(default_factory=HubCompatibility)
dependencies: HubDependencies = Field(default_factory=HubDependencies)
@@ -98,6 +103,7 @@ class HubIndexEntry(BaseModel):
useCases: list[str] = Field(default_factory=list)
trust: str = "community"
riskLevel: str = "low"
+ edition: PluginEdition = "oss"
manifestPath: str
@@ -162,6 +168,7 @@ class HubCatalogEntry(BaseModel):
capabilities: list[str] = Field(default_factory=list)
trust: str = "community"
riskLevel: str = "low"
+ edition: PluginEdition = "oss"
state: PluginState = "available"
installedVersion: Optional[str] = None
source: str = "bundled"
diff --git a/flocks/provider/sdk/anthropic.py b/flocks/provider/sdk/anthropic.py
index c07caef02..cda5b49e1 100644
--- a/flocks/provider/sdk/anthropic.py
+++ b/flocks/provider/sdk/anthropic.py
@@ -21,6 +21,18 @@
log = Log.create(service="provider.anthropic")
+def _map_stop_reason(stop_reason: Optional[str]) -> str:
+ """Map an Anthropic stop_reason onto the OpenAI-style finish_reason that the
+ rest of the pipeline speaks. ``max_tokens`` must survive as ``length`` --
+ that is the signal the tool-argument truncation guard keys on.
+ """
+ if stop_reason == "max_tokens":
+ return "length"
+ if stop_reason == "tool_use":
+ return "tool_calls"
+ return "stop"
+
+
class AnthropicProvider(BaseProvider):
"""Anthropic (Claude) provider with tool support."""
@@ -483,6 +495,10 @@ async def chat_stream(
output_tokens: int = 0
cache_read_tokens: int = 0
cache_write_tokens: int = 0
+ # Anthropic reports why generation ended on message_delta only; without
+ # keeping it, message_stop below always said "stop" and a max_tokens cut
+ # was indistinguishable from a clean finish downstream.
+ stream_stop_reason: Optional[str] = None
try:
stream_target = client.messages
@@ -603,6 +619,11 @@ async def chat_stream(
usage = getattr(event, 'usage', None)
if usage:
output_tokens = getattr(usage, 'output_tokens', output_tokens) or output_tokens
+ delta_obj = getattr(event, 'delta', None)
+ if delta_obj is not None:
+ stop_reason = getattr(delta_obj, 'stop_reason', None)
+ if stop_reason:
+ stream_stop_reason = stop_reason
elif event.type == "message_stop":
# Build usage metadata from captured token counts.
@@ -626,7 +647,7 @@ async def chat_stream(
})
yield StreamChunk(
delta="",
- finish_reason="stop",
+ finish_reason=_map_stop_reason(stream_stop_reason),
usage=usage_meta if usage_meta else None,
)
diff --git a/flocks/provider/sdk/openai_base.py b/flocks/provider/sdk/openai_base.py
index eecb5b2ba..33d1ca629 100644
--- a/flocks/provider/sdk/openai_base.py
+++ b/flocks/provider/sdk/openai_base.py
@@ -1246,6 +1246,7 @@ async def chat_stream(
)
tool_calls: Dict[int, Dict[str, Any]] = {}
started_tool_inputs: set[int] = set()
+ forwarded_args_len: Dict[int, int] = {}
emitted_substantive_chunk = False
stream_usage: Optional[Dict[str, int]] = None
usage_emitted = False
@@ -1329,7 +1330,7 @@ async def chat_stream(
delta_tcs = getattr(delta, "tool_calls", None)
if delta_tcs:
emitted_substantive_chunk = True
- tool_input_markers: List[Dict[str, Any]] = []
+ tool_call_deltas: List[Dict[str, Any]] = []
for tc in delta_tcs:
idx = tc.index
if idx not in tool_calls:
@@ -1348,26 +1349,40 @@ async def chat_stream(
tc.function.arguments
)
accumulated_name = tool_calls[idx]["function"]["name"]
- if accumulated_name and idx not in started_tool_inputs:
- tool_input_markers.append({
- "index": idx,
- "id": tool_calls[idx]["id"],
- "type": "function",
- "function": {
- "name": accumulated_name,
- "arguments": "",
- },
- })
- started_tool_inputs.add(idx)
-
- # Surface the tool as soon as its name is known, but keep
- # partial JSON private. The terminal chunk below publishes
- # the complete input once the model finishes generating it.
- if tool_input_markers:
+ if not accumulated_name:
+ # Arguments can precede the name; they stay buffered
+ # and are forwarded once the name is known.
+ continue
+ accumulated_args = tool_calls[idx]["function"]["arguments"]
+ already_sent = forwarded_args_len.get(idx, 0)
+ pending_args = accumulated_args[already_sent:]
+ is_first = idx not in started_tool_inputs
+ if not is_first and not pending_args:
+ continue
+ started_tool_inputs.add(idx)
+ forwarded_args_len[idx] = len(accumulated_args)
+ tool_call_deltas.append({
+ "index": idx,
+ "id": tool_calls[idx]["id"],
+ "type": "function",
+ "function": {
+ "name": accumulated_name,
+ "arguments": pending_args,
+ },
+ })
+
+ # Forward every argument fragment as it arrives. Holding the
+ # partial JSON back until finish_reason left this adapter
+ # silent for the whole generation, which starved the runner's
+ # mid-stream chunk watchdog on long write/edit calls even
+ # though the upstream was streaming the entire time. The
+ # downstream ToolCallAccumulator already appends fragments,
+ # and the terminal chunk below only carries the remainder.
+ if tool_call_deltas:
yield StreamChunk(
delta="",
finish_reason=None,
- tool_calls=tool_input_markers,
+ tool_calls=tool_call_deltas,
)
if choice.finish_reason:
@@ -1394,11 +1409,25 @@ async def chat_stream(
yield StreamChunk(delta=seg_text, finish_reason=None)
if tool_calls:
- sorted_calls = [
- {"index": i, **tool_calls[i]}
- for i in sorted(tool_calls.keys())
- ]
+ # Only the not-yet-forwarded tail goes out here; resending
+ # the whole argument string would duplicate everything the
+ # accumulator already appended from the deltas above.
+ sorted_calls = []
+ for i in sorted(tool_calls.keys()):
+ call = tool_calls[i]
+ sent = forwarded_args_len.get(i, 0)
+ fn = call["function"]
+ sorted_calls.append({
+ "index": i,
+ "id": call["id"],
+ "type": call["type"],
+ "function": {
+ "name": fn["name"],
+ "arguments": fn["arguments"][sent:],
+ },
+ })
tool_calls.clear()
+ forwarded_args_len.clear()
# Preserve real finish_reason (e.g. "length" when max_tokens
# hit) so the runner can detect truncated tool arguments.
terminal_chunk = StreamChunk(
diff --git a/flocks/server/routes/hub.py b/flocks/server/routes/hub.py
index 5c6882519..8d5568fe1 100644
--- a/flocks/server/routes/hub.py
+++ b/flocks/server/routes/hub.py
@@ -19,6 +19,7 @@
load_taxonomy,
)
from flocks.hub.files import file_tree, read_file_content
+from flocks.license import license_status
from flocks.hub.installer import install_plugin, uninstall_plugin, update_plugin
from flocks.hub.models import (
HubCatalogEntry,
@@ -29,7 +30,7 @@
InstalledPluginRecord,
PluginType,
)
-from flocks.server.auth import require_admin
+from flocks.server.auth import require_admin, require_user
from flocks.utils.log import Log
@@ -220,6 +221,95 @@ async def hub_plugin_file_content(plugin_type: PluginType, plugin_id: str, path:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+class SceneSuiteEntry(BaseModel):
+ """A scene suite as the scene workspace shows it: catalog state + workspace state."""
+
+ id: str
+ name: str
+ nameCn: Optional[str] = None
+ description: str = ""
+ descriptionCn: Optional[str] = None
+ version: str = "0.0.0"
+ installedVersion: Optional[str] = None
+ edition: str = "oss"
+ state: str = "available"
+ workspaceId: Optional[str] = None
+ workspaceTitle: Optional[str] = None
+ workspaceRoute: Optional[str] = None
+ workspaceEnabled: Optional[bool] = None
+
+
+def _suite_workspace_id(plugin_id: str) -> Optional[str]:
+ try:
+ manifest = load_manifest("component", plugin_id)
+ except Exception:
+ return None
+ for component in getattr(manifest, "components", []) or []:
+ if getattr(component, "type", None) == "webui":
+ return getattr(component, "id", None)
+ return None
+
+
+@router.get("/hub/scene-suites", response_model=list[SceneSuiteEntry])
+async def hub_scene_suites(_user: object = Depends(require_user)):
+ """Scene suites for the scene-workspace suite manager (Hub no longer lists them)."""
+ from flocks.contracts.webui.store import WebUIPagesStore, webui_contract_workspace_route
+
+ catalog = list_catalog()
+ entries = [entry for entry in catalog if entry.type == "component"]
+ webui_entries = {entry.id: entry for entry in catalog if entry.type == "webui"}
+ workspaces = {item.id: item for item in WebUIPagesStore().list_workspaces()}
+ suites: list[SceneSuiteEntry] = []
+ for entry in entries:
+ workspace_id = _suite_workspace_id(entry.id)
+ workspace = workspaces.get(workspace_id) if workspace_id else None
+ # A suite is only current when its pages are current too: the component
+ # shell and its WebUI package carry separate versions.
+ state = entry.state
+ child = webui_entries.get(workspace_id) if workspace_id else None
+ if state == "installed" and child is not None and child.state == "updateAvailable":
+ state = "updateAvailable"
+ suites.append(
+ SceneSuiteEntry(
+ id=entry.id,
+ name=entry.name,
+ nameCn=entry.nameCn,
+ description=entry.description,
+ descriptionCn=entry.descriptionCn,
+ version=entry.version,
+ installedVersion=entry.installedVersion,
+ edition=entry.edition,
+ state=state,
+ workspaceId=workspace_id,
+ workspaceTitle=workspace.title if workspace else None,
+ workspaceRoute=webui_contract_workspace_route(workspace.id) if workspace else None,
+ workspaceEnabled=workspace.enabled if workspace else None,
+ )
+ )
+ suites.sort(key=lambda item: (item.edition != "oss", item.id))
+ return suites
+
+
+async def _assert_edition_allowed(plugin_type: PluginType, plugin_id: str) -> None:
+ """Pro-only suites stay listed in the catalog but only install under Pro."""
+ try:
+ manifest = load_manifest(plugin_type, plugin_id)
+ except Exception:
+ return
+ if getattr(manifest, "edition", "oss") != "pro":
+ return
+ try:
+ status_payload = await license_status()
+ except Exception:
+ status_payload = {}
+ edition = str(status_payload.get("status") or "").lower()
+ if edition == "oss" or not status_payload.get("active"):
+ raise HTTPException(
+ status_code=403,
+ detail="该套件需要 Flocks Pro 授权后才能安装,请先在系统设置中升级。",
+ )
+
+
@router.post("/hub/plugins/{plugin_type}/{plugin_id}/install", response_model=InstalledPluginRecord)
async def hub_install_plugin(
plugin_type: PluginType,
@@ -228,6 +318,7 @@ async def hub_install_plugin(
_admin: object = Depends(require_admin),
):
_guard_legacy_removed_plugin(plugin_type, plugin_id)
+ await _assert_edition_allowed(plugin_type, plugin_id)
try:
return await install_plugin(plugin_type, plugin_id, scope=req.scope)
except Exception as exc:
@@ -243,6 +334,7 @@ async def hub_install_plugin_stream(
_admin: object = Depends(require_admin),
):
_guard_legacy_removed_plugin(plugin_type, plugin_id)
+ await _assert_edition_allowed(plugin_type, plugin_id)
if plugin_type != "component":
raise HTTPException(status_code=400, detail="Streaming install progress is only supported for components.")
try:
diff --git a/flocks/server/routes/webui.py b/flocks/server/routes/webui.py
index 0271b4a85..c42ceda6f 100644
--- a/flocks/server/routes/webui.py
+++ b/flocks/server/routes/webui.py
@@ -158,6 +158,27 @@ async def list_webui_workspaces(enabled_only: bool = Query(False, alias="enabled
return _store.list_workspaces(enabled_only=enabled_only)
+class WebUIWorkspaceEnabledRequest(BaseModel):
+ enabled: bool = Field(..., description="Whether the workspace appears in navigation")
+
+
+@router.patch("/contracts/webui/workspaces/{workspace_id}", response_model=WebUIWorkspaceListItem)
+async def set_webui_workspace_enabled(
+ workspace_id: str,
+ req: WebUIWorkspaceEnabledRequest,
+ _admin: object = Depends(require_admin),
+):
+ """Turn a scene workspace off or back on without uninstalling its suite."""
+ try:
+ workspace = _store.set_workspace_enabled(workspace_id, req.enabled)
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+ except FileNotFoundError as exc:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
+ await publish_event("contracts.webui.pages.nav_changed", {"workspaceId": workspace_id, "enabled": req.enabled})
+ return workspace
+
+
@router.post("/contracts/webui/pages", response_model=WebUIPageDetail, status_code=status.HTTP_201_CREATED)
async def create_webui_page(req: WebUIPageCreateRequest, _admin: object = Depends(require_admin)):
try:
diff --git a/flocks/session/streaming/tool_accumulator.py b/flocks/session/streaming/tool_accumulator.py
index bfd6536de..480afbe78 100644
--- a/flocks/session/streaming/tool_accumulator.py
+++ b/flocks/session/streaming/tool_accumulator.py
@@ -51,6 +51,21 @@ def __init__(
)
+def _looks_truncated(accumulated_args: str) -> bool:
+ """True when the arguments are valid JSON that simply stops early.
+
+ Closing the open strings / brackets makes it parse, which is the signature
+ of a stream that was cut mid-argument. Text that stays unparseable after
+ that is malformed for some other reason and keeps going down the existing
+ invalid-tool path.
+ """
+ repaired = repair_truncated_json(accumulated_args)
+ if repaired == accumulated_args:
+ return False
+ _, repaired_ok = _parse_json_robust(repaired)
+ return repaired_ok
+
+
class ToolCallAccumulator:
"""Accumulates streamed tool-call JSON fragments and dispatches execution.
@@ -195,6 +210,45 @@ async def flush_remaining(
continue
arguments, ok = _parse_json_robust(accumulated_args)
+ if not ok and _looks_truncated(accumulated_args):
+ # The JSON has an unterminated string or unclosed bracket, i.e.
+ # the stream stopped mid-argument. Providers only sometimes say
+ # so via finish_reason ("stop" or None is common when a gateway
+ # drops the connection, and the Anthropic SDK path never reports
+ # "length" at all), so the shape of the JSON is the reliable
+ # signal. Repairing it and executing anyway silently wrote half
+ # a file and reported success.
+ detail = (
+ f"Tool arguments for '{tool_name}' stopped mid-value at "
+ f"{len(accumulated_args)} chars (unterminated JSON)."
+ )
+ error_msg = (
+ f"Output was truncated (finish_reason={stream_finish_reason!r}). "
+ f"{detail} The tool was not executed."
+ )
+ await self._processor.process_event(
+ ToolInputErrorEvent(
+ id=tc_id,
+ tool_name=tool_name,
+ input={
+ "tool": tool_name,
+ "arguments_preview": accumulated_args[:500],
+ "finish_reason": stream_finish_reason,
+ },
+ error=error_msg,
+ )
+ )
+ tc_data["failed"] = True
+ if truncation_error is None:
+ truncation_error = StreamToolArgumentsTruncatedError(
+ tool_call_id=tc_id,
+ tool_name=tool_name,
+ finish_reason=str(stream_finish_reason),
+ arguments_len=len(accumulated_args),
+ arguments_preview=accumulated_args[:500],
+ )
+ continue
+
if ok:
if not tc_data.get("input_started"):
await self._processor.process_event(
@@ -264,11 +318,14 @@ async def _try_repair(
accumulated_args: str,
tc_data: dict[str, Any],
) -> bool:
- """Attempt multiple repair strategies. Return True if repaired."""
- repaired_json = repair_truncated_json(accumulated_args)
+ """Attempt multiple repair strategies. Return True if repaired.
+
+ Only the tool *name* is repaired here. Padding truncated JSON back into
+ shape and executing it is what let a cut-off ``write`` land on disk as a
+ half file; ``flush_remaining`` now routes that case to the truncation
+ path before this method is reached.
+ """
jsons_to_try = [accumulated_args]
- if repaired_json != accumulated_args:
- jsons_to_try.append(repaired_json)
async def _try_exec(candidate: str, variants: list[str]) -> bool:
for v in variants:
@@ -292,24 +349,6 @@ async def _try_exec(candidate: str, variants: list[str]) -> bool:
return True
return False
- # Strategy 0: truncated JSON repair with original name
- if repaired_json != accumulated_args:
- args, ok = _parse_json_robust(repaired_json)
- if ok:
- schema = ToolRegistry.get_schema(tool_name)
- missing = [p for p in schema.required if p not in args] if schema else []
- if not missing:
- if not tc_data.get("input_started"):
- await self._processor.process_event(
- ToolInputStartEvent(id=tc_id, tool_name=tool_name)
- )
- await self._processor.process_event(
- ToolCallEvent(
- tool_call_id=tc_id, tool_name=tool_name, input=args,
- )
- )
- return True
-
# Strategy 1: case sensitivity
lower = tool_name.lower()
if lower != tool_name and ToolRegistry.get(lower) is not None:
diff --git a/tests/contracts/webui/test_store_workspace_state.py b/tests/contracts/webui/test_store_workspace_state.py
new file mode 100644
index 000000000..88ddbfe74
--- /dev/null
+++ b/tests/contracts/webui/test_store_workspace_state.py
@@ -0,0 +1,87 @@
+"""Turning a scene workspace off hides it and its pages without touching plugin files."""
+
+import json
+
+import pytest
+
+from flocks.contracts.webui.store import WebUIPagesStore
+
+
+def _write(path, payload):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
+
+
+@pytest.fixture
+def workspace_store(tmp_path, monkeypatch):
+ root = tmp_path / "webui_pages"
+ monkeypatch.setenv("FLOCKS_CONTRACTS_WEBUI_ROOT", str(root))
+ _write(
+ root / "scene_ui" / "workspace.json",
+ {
+ "id": "scene_ui",
+ "version": "1.0.0",
+ "title": "场景工作区",
+ "icon": "ShieldCheck",
+ "order": 10,
+ "enabled": True,
+ "placement": "sceneWorkspace",
+ "defaultPageId": "scene-overview",
+ },
+ )
+ _write(
+ root / "scene_ui" / "scene_overview" / "manifest.json",
+ {
+ "id": "scene-overview",
+ "title": "总览",
+ "route": "/contracts/webui/scene-overview",
+ "icon": "Shield",
+ "order": 10,
+ "enabled": True,
+ "placement": "home.after",
+ "entry": "src/index.tsx",
+ "updatedAt": 0,
+ },
+ )
+ return WebUIPagesStore()
+
+
+def test_workspace_starts_enabled(workspace_store: WebUIPagesStore):
+ workspaces = workspace_store.list_workspaces()
+ assert [item.id for item in workspaces] == ["scene_ui"]
+ assert workspaces[0].enabled is True
+ assert [page.id for page in workspaces[0].pages] == ["scene-overview"]
+
+
+def test_disable_hides_workspace_and_its_pages(workspace_store: WebUIPagesStore):
+ updated = workspace_store.set_workspace_enabled("scene_ui", False)
+
+ assert updated.enabled is False
+ assert [page.enabled for page in updated.pages] == [False]
+ assert workspace_store.list_workspaces(enabled_only=True) == []
+ assert workspace_store.list_pages(enabled_only=True) == []
+ # The page is still listed when the caller wants everything, just disabled.
+ assert [(page.id, page.enabled) for page in workspace_store.list_pages()] == [("scene-overview", False)]
+
+
+def test_enable_restores_workspace(workspace_store: WebUIPagesStore):
+ workspace_store.set_workspace_enabled("scene_ui", False)
+ restored = workspace_store.set_workspace_enabled("scene_ui", True)
+
+ assert restored.enabled is True
+ assert [item.id for item in workspace_store.list_workspaces(enabled_only=True)] == ["scene_ui"]
+ assert [page.id for page in workspace_store.list_pages(enabled_only=True)] == ["scene-overview"]
+
+
+def test_state_is_kept_out_of_the_plugin_files(workspace_store: WebUIPagesStore, tmp_path):
+ workspace_store.set_workspace_enabled("scene_ui", False)
+
+ manifest = json.loads((tmp_path / "webui_pages" / "scene_ui" / "workspace.json").read_text(encoding="utf-8"))
+ assert manifest["enabled"] is True, "the suite's own manifest must stay untouched"
+ state = json.loads((tmp_path / "webui_pages" / ".workspace-state.json").read_text(encoding="utf-8"))
+ assert state == {"enabled": {"scene_ui": False}}
+
+
+def test_unknown_workspace_id_is_rejected(workspace_store: WebUIPagesStore):
+ with pytest.raises(FileNotFoundError):
+ workspace_store.set_workspace_enabled("missing_ui", False)
diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py
index 60315efa6..831e7c15e 100644
--- a/tests/hub/test_hub_catalog.py
+++ b/tests/hub/test_hub_catalog.py
@@ -296,17 +296,17 @@ def test_catalog_uses_webui_workspace_version_for_inferred_installs(
entry = {item.id: item for item in list_catalog(plugin_type="webui")}["soc_ui"]
- assert entry.version == "1.1.5"
+ assert entry.version == "1.1.6"
assert entry.state == "updateAvailable"
assert entry.installedVersion == "1.0.0"
- workspace["version"] = "1.1.5"
+ workspace["version"] = "1.1.6"
workspace_path.write_text(json.dumps(workspace), encoding="utf-8")
refreshed = {item.id: item for item in list_catalog(plugin_type="webui")}["soc_ui"]
assert refreshed.state == "installed"
- assert refreshed.installedVersion == "1.1.5"
+ assert refreshed.installedVersion == "1.1.6"
def test_pentest_agents_are_listed_in_agent_catalog():
diff --git a/tests/hub/test_scene_suites.py b/tests/hub/test_scene_suites.py
new file mode 100644
index 000000000..2ddfd9dc7
--- /dev/null
+++ b/tests/hub/test_scene_suites.py
@@ -0,0 +1,64 @@
+"""Scene suites are the hub's component packages, gated by edition."""
+
+import pytest
+from fastapi import HTTPException
+
+from flocks.hub.catalog import list_catalog, load_manifest
+from flocks.server.routes.hub import _assert_edition_allowed, _suite_workspace_id
+
+
+def _component_entries():
+ return {entry.id: entry for entry in list_catalog() if entry.type == "component"}
+
+
+def test_bundled_scene_suites_declare_their_edition():
+ entries = _component_entries()
+
+ assert set(entries) == {"soc-workspace", "code-audit-workspace", "ai-redteam-workspace"}
+ assert entries["soc-workspace"].edition == "oss"
+ assert entries["code-audit-workspace"].edition == "pro"
+ assert entries["ai-redteam-workspace"].edition == "pro"
+
+
+@pytest.mark.parametrize(
+ ("suite_id", "workspace_id"),
+ [
+ ("soc-workspace", "soc_ui"),
+ ("code-audit-workspace", "code_audit_ui"),
+ ("ai-redteam-workspace", "ai_redteam_ui"),
+ ],
+)
+def test_suite_resolves_to_its_workspace(suite_id: str, workspace_id: str):
+ assert _suite_workspace_id(suite_id) == workspace_id
+
+
+def test_pro_workspaces_are_scene_workspaces():
+ for workspace_id in ("code_audit_ui", "ai_redteam_ui"):
+ manifest = load_manifest("webui", workspace_id)
+ assert manifest.edition == "pro"
+
+
+@pytest.mark.asyncio
+async def test_oss_may_install_an_oss_suite():
+ # No exception: the OSS license checker reports edition "oss" and this suite
+ # does not require Pro.
+ await _assert_edition_allowed("component", "soc-workspace")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("suite_id", ["code-audit-workspace", "ai-redteam-workspace"])
+async def test_oss_may_not_install_a_pro_suite(suite_id: str):
+ with pytest.raises(HTTPException) as excinfo:
+ await _assert_edition_allowed("component", suite_id)
+
+ assert excinfo.value.status_code == 403
+ assert "Flocks Pro" in excinfo.value.detail
+
+
+@pytest.mark.asyncio
+async def test_pro_license_unlocks_a_pro_suite(monkeypatch):
+ async def pro_status():
+ return {"activated": True, "active": True, "status": "pro"}
+
+ monkeypatch.setattr("flocks.server.routes.hub.license_status", pro_status)
+ await _assert_edition_allowed("component", "code-audit-workspace")
diff --git a/tests/provider/test_anthropic_stop_reason.py b/tests/provider/test_anthropic_stop_reason.py
new file mode 100644
index 000000000..c2291b98a
--- /dev/null
+++ b/tests/provider/test_anthropic_stop_reason.py
@@ -0,0 +1,119 @@
+"""The Anthropic streaming path must report why generation stopped.
+
+``stop_reason`` only ever appears on ``message_delta``; ``message_stop`` used to
+hard-code ``finish_reason="stop"``, so a ``max_tokens`` cut was indistinguishable
+from a clean finish and the tool-argument truncation guard could never fire on
+Anthropic models.
+"""
+
+from types import SimpleNamespace
+
+import pytest
+
+from flocks.provider.provider import ChatMessage
+from flocks.provider.sdk.anthropic import AnthropicProvider
+
+
+class _FakeStream:
+ def __init__(self, events):
+ self._events = events
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+ def __aiter__(self):
+ async def gen():
+ for event in self._events:
+ yield event
+
+ return gen()
+
+
+def _events(stop_reason: str, *, tool_call: bool = True):
+ events = [
+ SimpleNamespace(
+ type="message_start",
+ message=SimpleNamespace(
+ usage=SimpleNamespace(
+ input_tokens=10,
+ output_tokens=0,
+ cache_read_input_tokens=0,
+ cache_creation_input_tokens=0,
+ )
+ ),
+ )
+ ]
+ if tool_call:
+ events += [
+ SimpleNamespace(
+ type="content_block_start",
+ index=0,
+ content_block=SimpleNamespace(type="tool_use", id="toolu_1", name="write"),
+ ),
+ SimpleNamespace(
+ type="content_block_delta",
+ index=0,
+ delta=SimpleNamespace(
+ type="input_json_delta",
+ partial_json='{"filePath": "/tmp/x.txt", "content": "line 1\\nline 2',
+ ),
+ ),
+ SimpleNamespace(type="content_block_stop", index=0),
+ ]
+ events += [
+ SimpleNamespace(
+ type="message_delta",
+ delta=SimpleNamespace(stop_reason=stop_reason, stop_sequence=None),
+ usage=SimpleNamespace(output_tokens=4096),
+ ),
+ SimpleNamespace(type="message_stop"),
+ ]
+ return events
+
+
+async def _finish_reason_for(stop_reason: str, **kwargs) -> str:
+ provider = AnthropicProvider()
+ provider._client = SimpleNamespace(
+ messages=SimpleNamespace(stream=lambda **kw: _FakeStream(_events(stop_reason, **kwargs)))
+ )
+ chunks = [
+ chunk
+ async for chunk in provider.chat_stream(
+ "claude-sonnet-5",
+ [ChatMessage(role="user", content="write a long file")],
+ tools=[
+ {
+ "type": "function",
+ "function": {
+ "name": "write",
+ "description": "write a file",
+ "parameters": {"type": "object", "properties": {}},
+ },
+ }
+ ],
+ )
+ ]
+ finishes = [c.finish_reason for c in chunks if c.finish_reason]
+ return finishes[-1] if finishes else ""
+
+
+class TestAnthropicStreamStopReason:
+ @pytest.mark.asyncio
+ async def test_max_tokens_surfaces_as_length(self):
+ """This is the value tool_accumulator's truncation guard keys on."""
+ assert await _finish_reason_for("max_tokens") == "length"
+
+ @pytest.mark.asyncio
+ async def test_tool_use_surfaces_as_tool_calls(self):
+ assert await _finish_reason_for("tool_use") == "tool_calls"
+
+ @pytest.mark.asyncio
+ async def test_end_turn_still_surfaces_as_stop(self):
+ assert await _finish_reason_for("end_turn", tool_call=False) == "stop"
+
+ @pytest.mark.asyncio
+ async def test_missing_stop_reason_defaults_to_stop(self):
+ assert await _finish_reason_for("", tool_call=False) == "stop"
diff --git a/tests/provider/test_openai_base_provider.py b/tests/provider/test_openai_base_provider.py
index ad05669bc..e0b3fa5f2 100644
--- a/tests/provider/test_openai_base_provider.py
+++ b/tests/provider/test_openai_base_provider.py
@@ -828,7 +828,7 @@ async def _stream_from_chunks(*chunks):
class TestOpenAIBaseProviderStreamingToolCalls:
@pytest.mark.asyncio
- async def test_chat_stream_emits_name_only_marker_before_complete_tool_input(self):
+ async def test_chat_stream_forwards_tool_argument_fragments_as_they_arrive(self):
provider = MockProviderWithoutCatalog()
create = AsyncMock()
provider._client = MagicMock()
@@ -904,28 +904,45 @@ async def test_chat_stream_emits_name_only_marker_before_complete_tool_input(sel
)
]
- assert len(chunks) == 2
+ # Each fragment must leave the adapter as it arrives: buffering the whole
+ # argument JSON until finish_reason starved the runner's mid-stream chunk
+ # watchdog on long write calls.
+ assert len(chunks) == 3
assert chunks[0].finish_reason is None
assert chunks[0].tool_calls == [
{
"index": 0,
"id": "call_write",
"type": "function",
- "function": {"name": "write", "arguments": ""},
+ "function": {"name": "write", "arguments": first_arguments},
}
]
- assert chunks[1].finish_reason == "tool_calls"
+ assert chunks[1].finish_reason is None
assert chunks[1].tool_calls == [
{
"index": 0,
"id": "call_write",
"type": "function",
- "function": {
- "name": "write",
- "arguments": first_arguments + remaining_arguments,
- },
+ "function": {"name": "write", "arguments": remaining_arguments},
+ }
+ ]
+ # The terminal chunk carries only what has not been forwarded yet, so the
+ # accumulator downstream does not append the arguments a second time.
+ assert chunks[2].finish_reason == "tool_calls"
+ assert chunks[2].tool_calls == [
+ {
+ "index": 0,
+ "id": "call_write",
+ "type": "function",
+ "function": {"name": "write", "arguments": ""},
}
]
+ streamed = "".join(
+ tc["function"]["arguments"]
+ for chunk in chunks
+ for tc in (chunk.tool_calls or [])
+ )
+ assert streamed == first_arguments + remaining_arguments
class TestOpenAIBaseProviderStreamingUsage:
diff --git a/tests/session/test_write_truncation_guard.py b/tests/session/test_write_truncation_guard.py
new file mode 100644
index 000000000..2f5a77dd9
--- /dev/null
+++ b/tests/session/test_write_truncation_guard.py
@@ -0,0 +1,196 @@
+"""Regression tests for silently half-written files from truncated tool calls.
+
+A ``write`` whose argument JSON was cut off mid-content used to be repaired
+(closing quote + brace appended) and then executed, so the file landed on disk
+with half its content and the tool reported "Wrote file successfully.". The
+existing guard only covered ``finish_reason`` in ("length", "max_tokens"), which
+neither a gateway that drops the connection nor the Anthropic streaming path
+ever produces.
+
+Also covers the adapter side: ``openai_base`` now forwards tool-call argument
+fragments as they arrive, and its terminal chunk must carry only the remainder
+so the accumulator does not append everything twice.
+"""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from flocks.session.streaming.tool_accumulator import (
+ StreamToolArgumentsTruncatedError,
+ ToolCallAccumulator,
+)
+
+
+WRITE_ARGS = json.dumps(
+ {"filePath": "/tmp/report.md", "content": "".join(f"line {i}\n" for i in range(400))}
+)
+
+
+def _make_accumulator():
+ processor = MagicMock()
+ processor.process_event = AsyncMock()
+ return ToolCallAccumulator(processor), processor
+
+
+def _tool_call_events(processor):
+ return [
+ c.args[0]
+ for c in processor.process_event.call_args_list
+ if c.args[0].type == "tool-call"
+ ]
+
+
+class TestTruncatedArgumentsAreNotExecuted:
+ @pytest.mark.parametrize("finish_reason", ["stop", None, "tool_calls"])
+ @pytest.mark.asyncio
+ async def test_cut_off_write_is_not_executed(self, finish_reason):
+ """The shape of the JSON, not finish_reason, decides that it was cut."""
+ acc, proc = _make_accumulator()
+ truncated = WRITE_ARGS[: len(WRITE_ARGS) // 2]
+ acc._accumulator["call_write"] = {
+ "id": "call_write",
+ "name": "write",
+ "arguments_str": truncated,
+ "completed": False,
+ }
+
+ with pytest.raises(StreamToolArgumentsTruncatedError) as err:
+ await acc.flush_remaining(finish_reason)
+
+ assert err.value.tool_name == "write"
+ assert err.value.arguments_len == len(truncated)
+ assert not _tool_call_events(proc), "a truncated write must not execute"
+ errors = [
+ c.args[0]
+ for c in proc.process_event.call_args_list
+ if c.args[0].type == "tool-input-error"
+ ]
+ assert errors and "not executed" in errors[0].error
+
+ @pytest.mark.asyncio
+ async def test_complete_write_still_executes(self):
+ acc, proc = _make_accumulator()
+ acc._accumulator["call_write"] = {
+ "id": "call_write",
+ "name": "write",
+ "arguments_str": WRITE_ARGS,
+ "completed": False,
+ }
+
+ await acc.flush_remaining("stop")
+
+ calls = _tool_call_events(proc)
+ assert [c.tool_name for c in calls] == ["write"]
+ assert calls[0].input == json.loads(WRITE_ARGS)
+
+ @pytest.mark.asyncio
+ async def test_malformed_but_not_truncated_still_uses_invalid_path(self):
+ """Garbage that stays unparseable after closing brackets is not truncation."""
+ acc, proc = _make_accumulator()
+ with patch("flocks.session.streaming.tool_accumulator.ToolRegistry") as reg:
+ reg.get_schema.return_value = None
+ reg.get.return_value = None
+ with patch(
+ "flocks.session.streaming.tool_accumulator._find_similar_tool",
+ return_value=None,
+ ):
+ acc._accumulator["call_bad"] = {
+ "id": "call_bad",
+ "name": "some_tool",
+ "arguments_str": "{{broken json{{",
+ "completed": False,
+ }
+ await acc.flush_remaining("stop")
+
+ assert any(c.tool_name == "invalid" for c in _tool_call_events(proc))
+
+
+class TestAdapterFragmentsReachAccumulatorExactlyOnce:
+ @pytest.mark.asyncio
+ async def test_streamed_fragments_are_not_duplicated(self):
+ """Drive the real adapter into the real accumulator.
+
+ Fix 1 streams the argument fragments *and* still emits a terminal
+ tool_calls chunk. If that terminal chunk repeated the whole argument
+ string, the accumulator would concatenate the payload twice and the
+ file would be written with duplicated content.
+ """
+ from flocks.provider.provider import ChatMessage
+ from flocks.provider.sdk.openai_base import OpenAIBaseProvider
+
+ class _Provider(OpenAIBaseProvider):
+ DEFAULT_BASE_URL = "https://api.example.com/v1"
+ ENV_API_KEY = ["EXAMPLE_API_KEY"]
+ ENV_BASE_URL = "EXAMPLE_BASE_URL"
+ CATALOG_ID = ""
+
+ def __init__(self):
+ super().__init__(provider_id="example", name="Example")
+
+ provider = _Provider()
+ provider._client = MagicMock()
+ provider._client.chat.completions.create = AsyncMock(
+ return_value=_fake_openai_stream(WRITE_ARGS, n_fragments=25)
+ )
+
+ acc, proc = _make_accumulator()
+ n_arg_chunks = 0
+ with patch("flocks.session.streaming.tool_accumulator.ToolRegistry") as reg:
+ reg.get_schema.return_value = None
+ async for chunk in provider.chat_stream(
+ "some-model",
+ [ChatMessage(role="user", content="write a long file")],
+ tools=[{"type": "function", "function": {"name": "write"}}],
+ ):
+ for tc in chunk.tool_calls or []:
+ if (tc.get("function") or {}).get("arguments"):
+ n_arg_chunks += 1
+ await acc.feed_chunk(tc)
+
+ assert n_arg_chunks > 3, "arguments must reach the runner as several chunks"
+ calls = _tool_call_events(proc)
+ assert [c.tool_name for c in calls] == ["write"]
+ assert calls[0].input == json.loads(WRITE_ARGS)
+
+
+def _fake_openai_stream(full_args: str, n_fragments: int):
+ """An OpenAI-style SSE stream that emits one tool call in many fragments."""
+ from types import SimpleNamespace
+
+ def _chunk(*, arguments=None, name=None, tc_id=None, finish=None):
+ tool_calls = None
+ if arguments is not None or name or tc_id:
+ tool_calls = [
+ SimpleNamespace(
+ index=0,
+ id=tc_id,
+ function=SimpleNamespace(name=name, arguments=arguments),
+ )
+ ]
+ return SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ delta=SimpleNamespace(content=None, tool_calls=tool_calls),
+ finish_reason=finish,
+ )
+ ],
+ usage=None,
+ )
+
+ async def gen():
+ step = max(1, len(full_args) // n_fragments)
+ sent = 0
+ first = True
+ while sent < len(full_args):
+ yield _chunk(
+ arguments=full_args[sent : sent + step],
+ name="write" if first else None,
+ tc_id="call_write" if first else None,
+ )
+ sent += step
+ first = False
+ yield _chunk(finish="tool_calls")
+
+ return gen()
diff --git a/webui/index.html b/webui/index.html
index a62686330..14c9037d7 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -8,9 +8,10 @@