feat: initial commit

This commit is contained in:
Jeffrey Wu
2026-09-07 09:57:33 +08:00
commit f39f6ac881
66 changed files with 9859 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
---
name: ads-analysis
description: 分析广告账户投放数据,识别异常 Campaign,并形成优化建议。
---
# 广告账户分析
你必须先调用 `mock_ads_metrics` 获取指定账户和时间范围的数据,再进行分析。
- 不要声称拥有真实广告平台数据;本 Skill 只分析工具返回的课堂 fixture。
- 以工具数值为指标事实来源,图片仅可作为可见内容的辅助信号。
- 识别异常 Campaign 时说明判断依据,给出可执行且有优先级的建议。
+16
View File
@@ -0,0 +1,16 @@
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
AGENT_PROVIDER=
AGENT_MODEL=
AGENT_DISPLAY_NAME=
AGENT_SUPPORTS_IMAGES=false
AGENT_CONTEXT_WINDOW=
AGENT_MAX_TOKENS=
LLM_BASE_URL=
LLM_API=openai-responses
MODEL_CREDENTIAL_ENCRYPTION_KEY=
ALLOW_PRIVATE_LLM_BASE_URLS=false
SESSION_RUN_TIMEOUT_MS=120000
APPROVAL_TIMEOUT_MS=60000
MAX_IMAGE_BYTES=5242880
MAX_IMAGES_PER_MESSAGE=4
+9
View File
@@ -0,0 +1,9 @@
node_modules/
.pnpm-store/
dist/
coverage/
.env
workspace/sessions/
workspace/model-credentials.enc.json
*.log
.DS_Store
+10
View File
@@ -0,0 +1,10 @@
---
name: report-writer
description: 把分析结果整理为结构化中文报告,并保存最终草稿。
---
# 中文报告撰写
报告必须包含:账户总结、关键指标、异常 Campaign、建议、数据时间范围。
完成草稿后调用 `save_report_draft` 保存。该操作需要人工审批;如果被拒绝或超时,仍可在聊天中输出正文,并明确说明未保存。
+197
View File
@@ -0,0 +1,197 @@
# Agent Studio Mini
Agent Studio Mini 是一个用于内部培训的、可运行的最小 AI Agent Harness。它用 Pi Coding Agent SDK 驱动 Agent Loop,以 Fastify + SSE 暴露受控协议,用 assistant-ui 展示聊天、Tool、审批、图片附件与完整事件时间线。
当前仓库未附产品截图;启动后,`http://127.0.0.1:5173` 即是桌面三栏和移动端 Tabs 的真实演示界面。
## 架构
```mermaid
flowchart LR
UI[assistant-ui\nComposer / Thread / Tool UI] -->|HTTP + SSE| API[Fastify API]
API --> REG[SessionRegistry]
REG --> PI[Pi AgentSession]
API --> STORE[EventStore / ApprovalBroker]
PI --> LOADER[DefaultResourceLoader / Tools]
STORE --> VIEW[Timeline / Approval UI]
LOADER --> RES[Skills / Workspace / ModelRuntime]
```
```mermaid
sequenceDiagram
participant U as User
participant W as Web + assistant-ui
participant H as Harness
participant P as Pi AgentSession
U->>W: 文本 + 图片
W->>H: POST message (立即 202)
H->>P: prompt(text, { images })
P-->>H: stream / tool events
H-->>W: HarnessEvent over SSE
P->>H: save_report_draft preflight
H-->>W: approval.required
U->>H: Approve / Deny
H-->>P: allow / block
P-->>H: tool result + completion
```
职责边界很明确:Pi `AgentSession` 是 Agent Loop、模型调用和 Tool 生命周期的事实来源;Harness 管理 Session、事件序列、安全守卫、附件、模型凭证和审批;assistant-ui 只把 Harness 状态投影为聊天和 Tool UI,不直接调用模型,也不在浏览器执行服务端 Tool。
## 目录
```text
apps/server Fastify HTTP/SSE 组合层
apps/web React、assistant-ui 与三栏界面
packages/harness Session、Skills、Tools、Approval、安全边界
packages/shared 浏览器/服务端共享的 Zod 协议
.agents/skills ads-analysis 示例 Skill
.pi/skills report-writer 示例 Skill
fixtures 离线广告账户数据
workspace/sessions 运行时临时 Session(被 gitignore
docs 架构、映射、安全、课堂脚本与路线图
```
## 环境与启动
Pi 0.84.3 的实际 package engine 要求 Node.js `>=22.19.0`,因此本仓库采用该版本,而不是需求草案中的 Node 20 下限。需要 pnpm 11。
```bash
cp .env.example .env
pnpm install
pnpm dev
```
Server 默认监听 `127.0.0.1:3001`Vite Web 默认监听 `127.0.0.1:5173` 并代理 `/api``/health`。打开 `http://127.0.0.1:5173`。根命令还包括:
```bash
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
## 模型配置
服务器环境变量优先级低于当前 Web Session 的内存设置:
```dotenv
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
MODEL_CREDENTIAL_ENCRYPTION_KEY=
AGENT_PROVIDER=
AGENT_MODEL=
AGENT_DISPLAY_NAME=
AGENT_SUPPORTS_IMAGES=false
AGENT_CONTEXT_WINDOW=
AGENT_MAX_TOKENS=
LLM_BASE_URL=
LLM_API=openai-responses
ALLOW_PRIVATE_LLM_BASE_URLS=false
SESSION_RUN_TIMEOUT_MS=120000
APPROVAL_TIMEOUT_MS=60000
MAX_IMAGE_BYTES=5242880
MAX_IMAGES_PER_MESSAGE=4
```
服务启动时会从项目 `.env` 严格加载并校验上述默认模型字段;`AGENT_PROVIDER``AGENT_MODEL` 必须同时存在。当前 Web Session 的 Settings 覆盖环境默认值;未覆盖时新 Session 自动使用 `.env`,无需每次启动重新配置。也可在顶栏 Settings 配置 Provider ID、`openai-responses` / `openai-completions` / `anthropic-messages`、Base URL、Model ID、Vision 与 API Key。Preset 只是可编辑起点,不保证模型存在。没有显式模型或凭证时返回 `MODEL_NOT_CONFIGURED` / `MODEL_CREDENTIAL_NOT_CONFIGURED`,绝不选择“第一个可用模型”。
Provider、API 类型、Base URL、Model ID、Display Name、Vision、context/max token 等**非敏感模型配置**会以版本化 strict Zod 结构保存到浏览器 `localStorage`。刷新或 Reset 创建新 Session 后,在服务器没有环境模型配置时自动恢复这些字段;损坏、超限或旧格式数据会安全丢弃。Settings 中的“忘记本地配置”只清除这份浏览器配置,不影响当前 Session。
API Key 可直接由 `.env``OPENAI_API_KEY` / `ANTHROPIC_API_KEY` 提供。若在前端提交,则服务端使用 `MODEL_CREDENTIAL_ENCRYPTION_KEY` 做 AES-256-GCM 认证加密,密文写入 gitignored 的 `workspace/model-credentials.enc.json`;下次启动会按 `.env` 默认模型的连接 identity 自动恢复。浏览器仍不会把 Key 写入 Web Storage、IndexedDB 或 Service Worker cache。刷新或 Reset 后无需重复输入。读取配置只返回 `configured`、安全模型信息、末四位 `keyHint` 与存储类型。Clear Credentials 会同时清除当前 Runtime credential 和对应持久密文;换模型会销毁既有 Pi Session,下一次 Run 按新配置创建。密钥不会写入 Pi 全局 `auth.json` / `models.json`、URL、SSE 或日志。
生成 master key`openssl rand -base64 32`。该值必须稳定保存在服务器 `.env` 或 secret manager 中;丢失或更换后旧密文会 fail closed、无法解密。生产环境应迁移到 KMS/HSM 并设计密钥轮换。
自定义 Base URL 使用 WHATWG `URL`、协议/userinfo/fragment/query 检查与 DNS 解析;默认拒绝 loopback、private、link-local 和 metadata 地址。开发环境只放行显式 loopback HTTP;企业内网需明确设置 `ALLOW_PRIVATE_LLM_BASE_URLS=true`。模型 Provider SDK 的重定向策略不是本课堂 Harness 可控的公开扩展点,生产版应在出口代理处禁用跨主机重定向并重新校验每一跳。
连接测试只执行有界的本地配置校验并返回“首次运行验证”,不创建 Agent Run、不记录模型响应,也不伪造远端成功。
## Skills 与 progressive disclosure
启动时 `DefaultResourceLoader.reload()` 发现 `.agents/skills``.pi/skills``AGENT_SKILLS_DIRS`(使用操作系统 `path.delimiter`)。额外目录会 canonicalize、去重、验证;错误进入 Catalog diagnostics,不拖垮服务。`GET /api/skills` 只返回名称、描述、来源、安全展示路径和 diagnostics。
Harness 不在启动时读取并拼接所有 `SKILL.md` 正文。Pi 仅先看到 Catalog,匹配任务后通过 `read` 按需加载正文。`skill.requested` / `skill.loaded` **不是 Pi 原生事件**Harness 以已发现 Skill 的 canonical `SKILL.md` 路径索引,从成功的 `read` 请求/结果可靠推导;读取失败不会误报 loaded。
示例:
- `ads-analysis`:要求先调用离线 `mock_ads_metrics`,不声称有真实平台数据。
- `report-writer`:生成结构化中文报告,并请求 `save_report_draft`
## Session、Events 与 SSE
每个 Web Session 有随机 ID、`workspace/sessions/<id>` 独立 cwd、`SessionManager.inMemory(cwd)`、事件 sequence、运行锁、AbortController、审批、附件与内存凭证。为避免无配置时触发 Pi 的模型 fallbackWeb Session 先建立,Pi `AgentSession` 在首次已配置 Run 时惰性创建;每个 Web Session 至多对应一个独立 Pi Session。
消息 API 接受后立即返回 `202 + runId`;同 Session 并发返回 `409 SESSION_BUSY`。用户取消与系统超时分别记录。删除顺序是 abort、取消审批、关闭订阅/SSE、dispose、清凭证/附件、校验目录边界、删除临时 cwd。
EventStore 原子分配单 Session 单调 sequence,保留最近 1000 条。SSE 使用 `id/event/data`、15 秒 heartbeat、`Last-Event-ID` 优先于 `afterSequence`、历史重放、窗口 gap 控制事件、慢客户端断开策略与连接清理。前端 reducer 按 eventId/sequence 去重并合并高频 delta。
完整映射见 [docs/event-mapping.md](docs/event-mapping.md)。
## Tools 与 Approval
显式 allowlist 只有 `read``grep``find``ls``mock_ads_metrics``save_report_draft`。默认不注册 bash/powershell/edit/write;未知 Tool deny。
- `mock_ads_metrics` 用当前 Pi `defineTool` + TypeBox,从 `fixtures/ads-account.json` 读取 1–30 天离线数据,计算除零安全、定点精度的指标。
- `save_report_draft` 只接受标题/正文,只能原子写当前 Session 的固定 `report.md`
Pi extension 的 `tool_call` preflight 在执行前阻塞 `save_report_draft`。服务器 `ApprovalBroker` 是唯一权威;Approve 才继续,Deny/Timeout/Abort 均 block,且生成 `approval.resolved`。重复相同决定幂等,冲突决定返回 409。前端卡片和 modal 只提交决定并等待 SSE 权威状态。
## 图片多模态链路
Composer 支持选择、拖拽、粘贴、上传状态、移除与对话框预览。自定义 assistant-ui `AttachmentAdapter` 立即流式上传,React 状态只保留 attachmentId 和临时 object URL;移除/卸载会 revoke URL。
服务端只接受 JPEG、PNG、WebP,并同时校验扩展名、声明 MIME 与 magic bytes。默认单图 5 MiB、单消息 4 张,并限制像素;Sharp 自动旋转、移除 EXIF/ICC 等元数据并重新编码。随机文件名只进入 Session 的 `attachments/`。SVG/GIF/PDF/视频、伪 MIME、跨 Session ID 与解压炸弹会被拒绝。
发送时服务器验证归属与 ready 状态,按已解析 Pi Model 的 image capability 拦截 text-only 模型,然后把规范化图片映射为安装版 Pi `ImageContent[]` 并调用 `session.prompt(text, { images })`。base64 只存在于这一步的服务端瞬时调用,不进入事件或 React 全局状态。
## Demo
点击“填入 Demo Prompt”(不会自动执行),或粘贴:
> 请使用 ads-analysis Skill,分析账户 demo-account 最近 7 天数据,找出异常 Campaign,并输出账户总结、关键指标和 3 条优化建议。最后使用 report-writer Skill 生成一份报告草稿。
可附一张广告素材/后台截图并补充:
> 请同时观察我上传的图片,将可见内容作为辅助信号;不要根据图片编造图片中不存在的数据,数值指标以 mock_ads_metrics 返回的数据为准。
预期路径:Skill requested/loaded → mock metrics Tool 卡片 → 分析 → report-writer → save Tool 卡片与审批提醒。Approve 后只写 Session `report.md`;Deny/超时不写文件,聊天仍可呈现正文。10–15 分钟完整讲稿见 [docs/classroom-demo.md](docs/classroom-demo.md)。
## 安全边界
**Pi 没有内置 Sandbox。Project Trust 是项目资源加载防线,不是执行隔离。** Pi、Extension 和 Tool 继承 Node 进程权限。本项目通过最小 Tool allowlist、固定 Session cwd、canonical/realpath 路径守卫、符号链接拒绝、protected-path preflight、固定写入目标、审批、SSRF 检查、图片重编码和日志/事件脱敏降低风险,但这些都不等于 OS Sandbox。
不要把不可信 Skill、脚本、仓库或无人值守任务直接交给本课堂进程。生产环境必须放入 Docker、VM、micro-VM 或远程 Sandbox,并配置受控网络出口。更多边界见 [docs/security-boundaries.md](docs/security-boundaries.md)。
## 版本与 API 核对
编码前核对了 Pi 的 [SDK](https://pi.dev/docs/latest/sdk)、[Skills](https://pi.dev/docs/latest/skills)、[Extensions](https://pi.dev/docs/latest/extensions)、[Security](https://pi.dev/docs/latest/security)、[Models](https://pi.dev/docs/latest/models)、[Providers](https://pi.dev/docs/latest/providers),以及 assistant-ui 的 [Custom Runtime](https://www.assistant-ui.com/docs/runtimes/custom/external-store)、[Tools](https://www.assistant-ui.com/docs/tools) 与 [Attachments](https://www.assistant-ui.com/docs/guides/attachments)。安装后再以 `node_modules` 的公开 `.d.ts` 和可验证行为为准。
| 依赖 | 实装版本 | 使用的公开接口 |
| --- | --- | --- |
| Pi Coding Agent / pi-ai | 0.84.3 | `createAgentSession`, `DefaultResourceLoader`, `SettingsManager.inMemory`, `SessionManager.inMemory`, `ModelRuntime`, `InMemoryCredentialStore`, `defineTool`, `isToolCallEventType` |
| TypeBox | 1.3.7 | Pi Tool 参数 schema |
| assistant-ui React | 0.15.17 | `useExternalStoreRuntime`, Attachment Adapter, Toolkit + `Tools`, Thread/Composer primitives |
| Fastify | 5.12.1 | HTTP、multipart、SSE |
| React / Vite | 19.2.8 / 7.3.6 | Web UI / build |
| Zod | 4.4.3 | HTTP、事件、模型与消息边界 |
发现的实际差异:Pi 文档示例中的图片结构仍展示嵌套 `source`,但 0.84.3 导出的稳定 `ImageContent``{ type: "image", data, mimeType }`,本实现服从安装包类型。Pi 0.84.3 也要求 Node `>=22.19.0`。assistant-ui 包仍导出部分历史 helper,但本项目没有使用弃用的 `makeAssistantTool` / `useAssistantToolUI`,采用稳定 External Store Runtime 和 Toolkit renderers;没有使用 `unstable_*` API。
## 测试与当前结果
默认测试使用 fake `AgentSessionPort`,不需要 API Key、不会访问模型或外网。覆盖 shared schemas、Skill discovery/diagnostics、canonical Skill 事件、Tools、安全路径、Approval 全状态、Session 并发/取消/清理、模型无 fallback/SSRF/脱敏、JPEG/PNG/WebP、伪 MIME/大小/跨 Session、SSE replay/heartbeat/cleanup、assistant-ui reducer/Tool UI/Settings/附件与可访问性。
2026-08-28 本机验证:
- `pnpm lint`:通过,0 warning。
- `pnpm typecheck`4 个 workspace package 全部通过。
- `pnpm test`8 个 test files、88 个 tests 全部通过;其中包含 `.env` 默认模型严格加载、环境 API Key 脱敏、加密凭证按默认模型跨重启恢复、模型元数据恢复、AES-256-GCM 凭证跨 Session 恢复/清除/错误 master key fail-closed、API Key 不进入 Web Storage、Session 初始化竞态、真实重复 Tool/Approval 事件链去重与 UI error fallback,以及使用精确自定义模型、关闭模型网络刷新后创建并 dispose 真实 Pi `AgentSession` 的测试。
- `pnpm build`shared、harness、server、web 全部通过;Vite 提示单一 JS chunk 约 603 kB,可在生产化时拆包,但不影响构建。
- `PORT=3010 pnpm --filter @agent-studio/server start`:编译产物启动成功,`GET /health` 返回 200,随后 SIGINT 优雅关闭。
人工浏览器验收:桌面三栏、无模型禁发、Settings password 显隐/脱敏回显、Vision badge、Demo Prompt 只填不发、SSE Timeline、390px 移动端 Tabs 与无横向溢出均通过;应用控制台无 error/warn。浏览器自动化环境拒绝设置本地 file chooser,因此上传点击的人工自动化未完成,实际 multipart 上传、附件 adapter、图片预览/移除和 text-only 拦截由通过的 Server/Web 测试覆盖。
## 已知限制与生产化
课堂版服务端仍是单进程:重启会丢 Session/Event/Approval,但模型 API Key 可通过本地 AES-256-GCM 密文恢复;浏览器只保留非敏感模型元数据。当前文件 vault 没有多用户/租户隔离、远程 KMS、密钥轮换或分布式锁。SSE 无跨实例协调;没有用户身份、CSRF、分布式 rate limit、持久审计、对象存储/KMS 或 OS Sandbox。自定义 provider 的 Vision 声明会被服务器注册成精确 Pi Model 并按该模型检查,但课堂版不会主动发远端 capability probe。
生产化路线见 [docs/production-roadmap.md](docs/production-roadmap.md)Redis/Postgres、对象存储、KMS、身份鉴权与 CSRF、速率/配额、容器/VM Sandbox、出口代理、不可变审计日志、队列/worker、分布式 SSE,以及完整可观测性。
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@agent-studio/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": { "predev": "pnpm --filter @agent-studio/shared build && pnpm --filter @agent-studio/harness build", "dev": "tsx watch src/main.ts", "prestart": "pnpm --filter @agent-studio/shared build && pnpm --filter @agent-studio/harness build", "start": "node dist/main.js", "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.json" },
"dependencies": {
"@agent-studio/harness": "workspace:*",
"@agent-studio/shared": "workspace:*",
"@fastify/multipart": "^9.3.0",
"@fastify/static": "^8.3.0",
"fastify": "^5.6.2",
"zod": "^4.1.13"
},
"devDependencies": { "@types/node": "^24.10.1", "typescript": "^5.9.3" }
}
+123
View File
@@ -0,0 +1,123 @@
import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import multipart from "@fastify/multipart";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
import { ZodError, z } from "zod";
import {
FileAttachmentStore,
EncryptedFileCredentialVault,
HarnessError,
InMemoryEventStore,
InMemoryModelConnectionStore,
InMemorySessionRegistry,
PiAgentSessionFactory,
SkillCatalog,
safeErrorMessage,
type AgentSessionFactory,
} from "@agent-studio/harness";
import { modelConnectionInputSchema, sendMessageSchema } from "@agent-studio/shared";
const idParams = z.object({ sessionId: z.string().min(1) });
const attachmentParams = idParams.extend({ attachmentId: z.string().min(1) });
const approvalParams = z.object({ approvalId: z.string().min(1) });
const eventQuery = z.object({ afterSequence: z.coerce.number().int().nonnegative().optional() });
function parse<T>(schema: z.ZodType<T>, value: unknown): T { return schema.parse(value); }
export interface AppServices { sessions: InMemorySessionRegistry; events: InMemoryEventStore; attachments: FileAttachmentStore; models: InMemoryModelConnectionStore; skills: SkillCatalog }
export interface AppOptions { projectRoot?: string; runTimeoutMs?: number; approvalTimeoutMs?: number; heartbeatMs?: number; agentFactory?: AgentSessionFactory; onReady?: (services: AppServices) => void }
export async function buildApp(options: AppOptions = {}): Promise<FastifyInstance> {
const projectRoot = options.projectRoot ?? path.resolve(import.meta.dirname, "../../..");
const sessionsRoot = path.join(projectRoot, "workspace", "sessions");
const agentDir = path.join(projectRoot, "workspace", ".pi-catalog");
await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
await mkdir(agentDir, { recursive: true, mode: 0o700 });
const events = new InMemoryEventStore(1_000);
const credentialVault = process.env.MODEL_CREDENTIAL_ENCRYPTION_KEY
? new EncryptedFileCredentialVault(path.join(projectRoot, "workspace", "model-credentials.enc.json"), process.env.MODEL_CREDENTIAL_ENCRYPTION_KEY)
: undefined;
const models = new InMemoryModelConnectionStore(process.env, credentialVault);
// Validate and cache the .env default before any Web Session is accepted.
await models.initialize();
const attachments = new FileAttachmentStore(sessionsRoot, Number(process.env.MAX_IMAGE_BYTES ?? 5 * 1024 * 1024));
const skills = new SkillCatalog(projectRoot, agentDir);
await skills.reload();
const sessions = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, skills, undefined, options.approvalTimeoutMs ?? Number(process.env.APPROVAL_TIMEOUT_MS ?? 60_000), options.runTimeoutMs ?? Number(process.env.SESSION_RUN_TIMEOUT_MS ?? 120_000));
sessions.setFactory(options.agentFactory ?? new PiAgentSessionFactory(path.join(projectRoot, "fixtures", "ads-account.json"), skills, models, sessions.approvals));
options.onReady?.({ sessions, events, attachments, models, skills });
const app = Fastify({
genReqId: () => `req_${randomUUID()}`,
logger: process.env.NODE_ENV === "test" ? false : { level: process.env.LOG_LEVEL ?? "info", redact: { paths: ["req.headers.authorization", "req.body.apiKey", "*.apiKey", "*.authorization"], censor: "[REDACTED]" } },
bodyLimit: 1_000_000,
});
await app.register(multipart, { limits: { files: 1, fileSize: Number(process.env.MAX_IMAGE_BYTES ?? 5 * 1024 * 1024) + 1, fields: 4 } });
app.addHook("onRequest", async (request, reply) => { reply.header("x-request-id", request.id); });
app.setErrorHandler((error, request, reply) => {
if (error instanceof ZodError) return sendError(reply, request, 400, "VALIDATION_ERROR", "Request validation failed", { fields: z.flattenError(error).fieldErrors });
if (error instanceof HarnessError) return sendError(reply, request, error.statusCode, error.code, error.message, error.details);
if ((error as { code?: string }).code === "FST_REQ_FILE_TOO_LARGE") return sendError(reply, request, 413, "ATTACHMENT_TOO_LARGE", "Image exceeds the configured size limit");
request.log.error({ requestId: request.id, code: "INTERNAL_ERROR", message: safeErrorMessage(error) }, "request failed");
return sendError(reply, request, 500, "INTERNAL_ERROR", "Internal server error");
});
app.get("/health", async (request) => ({ ok: true, requestId: request.id }));
app.get("/api/skills", async (request) => ({ skills: skills.list(), requestId: request.id }));
app.post("/api/sessions", async (request, reply) => { const session = await sessions.create(); return reply.code(201).send({ sessionId: session.sessionId, requestId: request.id }); });
app.delete("/api/sessions/:sessionId", async (request, reply) => { const { sessionId } = parse(idParams, request.params); await sessions.delete(sessionId); return reply.send({ deleted: true, requestId: request.id }); });
app.post("/api/sessions/:sessionId/messages", async (request, reply) => { const { sessionId } = parse(idParams, request.params); const body = parse(sendMessageSchema, request.body); const result = await sessions.send(sessionId, body); return reply.code(202).send({ ...result, requestId: request.id }); });
app.post("/api/sessions/:sessionId/cancel", async (request, reply) => { const { sessionId } = parse(idParams, request.params); if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); await sessions.cancel(sessionId); return reply.send({ cancelled: true, requestId: request.id }); });
app.get("/api/sessions/:sessionId/model-config", async (request) => { const { sessionId } = parse(idParams, request.params); if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); return { ...models.getRedacted(sessionId), requestId: request.id }; });
app.put("/api/sessions/:sessionId/model-config", async (request) => { const { sessionId } = parse(idParams, request.params); const input = parse(modelConnectionInputSchema, request.body); return { ...(await sessions.configureModel(sessionId, input)), requestId: request.id }; });
app.delete("/api/sessions/:sessionId/model-config/credentials", async (request) => { const { sessionId } = parse(idParams, request.params); await sessions.clearCredentials(sessionId); return { cleared: true, requestId: request.id }; });
app.post("/api/sessions/:sessionId/model-config/test", async (request) => { const { sessionId } = parse(idParams, request.params); if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); const config = models.getRedacted(sessionId); if (!config.configured) throw new HarnessError("MODEL_NOT_CONFIGURED", "No model connection is configured", 422); return { status: "deferred", message: "配置已通过本地校验;为避免记录模型正文,将在首次运行时验证连接。", requestId: request.id }; });
app.post("/api/sessions/:sessionId/attachments", async (request, reply) => {
const { sessionId } = parse(idParams, request.params);
if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404);
const file = await request.file();
if (!file) throw new HarnessError("ATTACHMENT_REQUIRED", "Multipart image file is required", 400);
try {
const item = await attachments.add(sessionId, { filename: file.filename, mimetype: file.mimetype, stream: file.file });
events.append(sessionId, { type: "attachment.uploaded", payload: { attachmentId: item.attachmentId, name: item.name, mediaType: item.mediaType, byteSize: item.byteSize, width: item.width, height: item.height, status: "ready" } });
return reply.code(201).send({ ...item, requestId: request.id });
} catch (error) {
events.append(sessionId, { type: "attachment.rejected", payload: { name: file.filename.slice(0, 200), code: error instanceof HarnessError ? error.code : "ATTACHMENT_REJECTED", message: error instanceof HarnessError ? error.message : "Attachment rejected" } });
throw error;
}
});
app.delete("/api/sessions/:sessionId/attachments/:attachmentId", async (request) => { const { sessionId, attachmentId } = parse(attachmentParams, request.params); if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); await attachments.remove(sessionId, attachmentId); return { deleted: true, requestId: request.id }; });
app.post("/api/approvals/:approvalId/approve", async (request) => { const { approvalId } = parse(approvalParams, request.params); const result = sessions.approvals.approve(approvalId); return { approval: result.request, requestId: request.id }; });
app.post("/api/approvals/:approvalId/deny", async (request) => { const { approvalId } = parse(approvalParams, request.params); const result = sessions.approvals.deny(approvalId); return { approval: result.request, requestId: request.id }; });
app.get("/api/sessions/:sessionId/events", async (request, reply) => {
const { sessionId } = parse(idParams, request.params);
if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404);
const query = parse(eventQuery, request.query);
const lastEventId = request.headers["last-event-id"];
const cursor = typeof lastEventId === "string" ? { eventId: lastEventId } : query.afterSequence !== undefined ? { sequence: query.afterSequence } : {};
reply.hijack();
const raw = reply.raw;
raw.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no", "x-request-id": request.id });
const write = (chunk: string) => { if (raw.writableLength > 1024 * 1024) { raw.destroy(); return; } raw.write(chunk); };
const batch = events.listAfter(sessionId, cursor);
if (batch.gap) write(`event: stream.gap\ndata: ${JSON.stringify({ type: "stream.gap", ...batch.gap })}\n\n`);
for (const item of batch.events) write(serializeEvent(item));
const unsubscribe = events.subscribe(sessionId, (item) => write(serializeEvent(item)));
const heartbeat = setInterval(() => write(`: heartbeat ${Date.now()}\n\n`), options.heartbeatMs ?? 15_000);
const cleanup = () => { clearInterval(heartbeat); unsubscribe(); };
request.raw.once("close", cleanup);
});
app.addHook("onClose", async () => { await sessions.shutdown(); });
return app;
}
function serializeEvent(event: { eventId: string; type: string } & Record<string, unknown>): string { return `id: ${event.eventId}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`; }
function sendError(reply: FastifyReply, request: FastifyRequest, status: number, code: string, message: string, details: Record<string, unknown> = {}) { return reply.code(status).send({ error: { code, message, requestId: request.id, details } }); }
+12
View File
@@ -0,0 +1,12 @@
import path from "node:path";
import { loadEnvFile } from "node:process";
import { buildApp } from "./app.js";
try { loadEnvFile(path.resolve(import.meta.dirname, "../../../.env")); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
const app = await buildApp();
const port = Number(process.env.PORT ?? 3001);
await app.listen({ host: "127.0.0.1", port });
const shutdown = async () => { await app.close(); process.exit(0); };
process.once("SIGINT", () => void shutdown());
process.once("SIGTERM", () => void shutdown());
+41
View File
@@ -0,0 +1,41 @@
import { mkdtemp, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { buildApp, type AppServices } from "../src/app.js";
import type { AgentSessionFactory, AgentSessionPort } from "@agent-studio/harness";
import type { FastifyInstance } from "fastify";
class ServerFakePort implements AgentSessionPort {
readonly modelId = "fake-model"; readonly supportsImages = true; disposed = false; private finish?: () => void;
prompt(): Promise<void> { return new Promise((resolve) => { this.finish = resolve; }); }
async abort(): Promise<void> { this.finish?.(); }
subscribe(): () => void { return () => undefined; }
dispose(): void { this.disposed = true; }
complete(): void { this.finish?.(); }
}
class ServerFakeFactory implements AgentSessionFactory { port = new ServerFakePort(); async create() { return this.port; } }
const apps: FastifyInstance[] = [];
afterEach(async () => { while (apps.length) await apps.pop()?.close(); });
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), "studio-server-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); await mkdir(path.join(root, "fixtures"));
const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, services: services! };
}
async function createSession(app: FastifyInstance) { const response = await app.inject({ method: "POST", url: "/api/sessions" }); return (response.json() as { sessionId: string }).sessionId; }
async function configure(app: FastifyInstance, sessionId: string, supportsImages = true) { return app.inject({ method: "PUT", url: `/api/sessions/${sessionId}/model-config`, payload: { providerId: "fake", api: "openai-responses", modelId: "fake-model", apiKey: "sk-test-never-return", supportsImages } }); }
describe("Fastify API", () => {
it("returns health and a request id", async () => { const { app } = await fixture(); const response = await app.inject({ method: "GET", url: "/health" }); expect(response.statusCode).toBe(200); expect(response.headers["x-request-id"]).toMatch(/^req_/); expect(response.json()).toMatchObject({ ok: true }); });
it("uses the unified 404 error envelope", async () => { const { app } = await fixture(); const response = await app.inject({ method: "POST", url: "/api/sessions/missing/cancel" }); expect(response.statusCode).toBe(404); expect(response.json()).toMatchObject({ error: { code: "SESSION_NOT_FOUND", message: "Session not found" } }); });
it("returns safe Zod field errors", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const response = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/messages`, payload: { text: "", attachmentIds: [] } }); expect(response.statusCode).toBe(400); expect(response.json()).toHaveProperty("error.details.fields"); });
it("never returns the API key from model config", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const saved = await configure(app, sessionId); expect(saved.statusCode).toBe(200); expect(saved.body).not.toContain("sk-test-never-return"); const read = await app.inject({ method: "GET", url: `/api/sessions/${sessionId}/model-config` }); expect(read.body).not.toContain("sk-test-never-return"); expect(read.json()).toMatchObject({ configured: true, keyConfigured: true, keyHint: "••••turn" }); });
it("rejects SSRF base URLs", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const response = await app.inject({ method: "PUT", url: `/api/sessions/${sessionId}/model-config`, payload: { providerId: "fake", api: "openai-responses", modelId: "fake", apiKey: "x", supportsImages: false, baseUrl: "https://169.254.169.254/latest" } }); expect(response.statusCode).toBe(400); expect(response.json()).toMatchObject({ error: { code: "SSRF_BLOCKED" } }); });
it("returns 202 without waiting for the Agent run", async () => { const { app, factory } = await fixture(); const sessionId = await createSession(app); await configure(app, sessionId); const response = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/messages`, payload: { text: "hello", attachmentIds: [] } }); expect(response.statusCode).toBe(202); expect(response.json()).toHaveProperty("runId"); factory.port.complete(); });
it("returns 409 for concurrent messages", async () => { const { app, factory } = await fixture(); const sessionId = await createSession(app); await configure(app, sessionId); await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/messages`, payload: { text: "one", attachmentIds: [] } }); const response = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/messages`, payload: { text: "two", attachmentIds: [] } }); expect(response.statusCode).toBe(409); expect(response.json()).toMatchObject({ error: { code: "SESSION_BUSY" } }); factory.port.complete(); });
it("supports approve and deny endpoints with conflict detection", async () => { const { app, services } = await fixture(); const sessionId = await createSession(app); const controller = new AbortController(); const pending = services.sessions.approvals.request({ sessionId, runId: "run_test", toolCallId: "tool_test", toolName: "save_report_draft", arguments: {}, riskLevel: "medium" }, controller.signal); const event = services.events.listAfter(sessionId).events.find((item) => item.type === "approval.required"); if (!event || event.type !== "approval.required") throw new Error("missing approval"); const approved = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/approve` }); expect(approved.statusCode).toBe(200); expect((await pending).status).toBe("approved"); const conflict = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/deny` }); expect(conflict.statusCode).toBe(409); });
it("accepts a valid multipart PNG and rejects SVG", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); const accepted = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "image.png", "image/png", png) }); expect(accepted.statusCode).toBe(201); expect(accepted.json()).toMatchObject({ mediaType: "image/png", width: 1, status: "ready" }); const rejected = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "x.svg", "image/svg+xml", Buffer.from("<svg/>")) }); expect(rejected.statusCode).toBe(415); });
it("serves SSE events and heartbeat comments", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const address = await app.listen({ host: "127.0.0.1", port: 0 }); const controller = new AbortController(); const response = await fetch(`${address}/api/sessions/${sessionId}/events`, { signal: controller.signal }); expect(response.headers.get("content-type")).toContain("text/event-stream"); const reader = response.body!.getReader(); let text = ""; for (let count = 0; count < 4 && !text.includes(": heartbeat"); count++) { const chunk = await reader.read(); text += new TextDecoder().decode(chunk.value); } controller.abort(); expect(text).toContain("event: session.started"); expect(text).toContain(": heartbeat"); });
});
function multipart(boundary: string, filename: string, mime: string, data: Buffer): Buffer { return Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]); }
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" },
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="theme-color" content="#111111" /><title>Agent Studio Mini</title></head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@agent-studio/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": { "dev": "vite --host 127.0.0.1", "typecheck": "tsc --noEmit", "build": "vite build" },
"dependencies": {
"@agent-studio/shared": "workspace:*",
"@assistant-ui/react": "0.15.17",
"@radix-ui/react-dialog": "^1.1.15",
"lucide-react": "^0.555.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"zod": "^4.1.13"
},
"devDependencies": { "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "typescript": "^5.9.3", "vite": "^7.2.6" }
}
+12
View File
@@ -0,0 +1,12 @@
import { useState } from "react";
import { Bot, RotateCcw, Settings } from "lucide-react";
import { ApprovalDialog } from "./components/ApprovalDialog";
import { ChatPanel } from "./components/ChatPanel";
import { SettingsDialog } from "./components/SettingsDialog";
import { SkillsPanel } from "./components/SkillsPanel";
import { TimelinePanel } from "./components/TimelinePanel";
import { HarnessToolsProvider } from "./components/ToolUIs";
import { useHarnessState, useHarnessStore } from "./assistant/HarnessContext";
type MobileTab = "skills" | "chat" | "events";
export function App() { const store = useHarnessStore(); const state = useHarnessState(); const [settings, setSettings] = useState(false); const [tab, setTab] = useState<MobileTab>("chat"); return <HarnessToolsProvider><div className="app-shell"><header className="topbar"><div className="brand"><span className="brand-mark"><Bot/></span><div><strong>Agent Studio Mini</strong><small>Pi Coding Agent Harness</small></div></div><div className="session-summary"><span className={`run-dot ${state.isRunning ? "running" : ""}`}/><span>{state.sessionId?.slice(0, 16) ?? "creating session…"}</span><span className="model-badge">{state.model.modelId ?? "Model missing"}</span>{state.model.supportsImages && <span className="vision-badge">Vision</span>}<button className="icon-button" title="Reset Session" aria-label="重置 Session" disabled={state.isRunning} onClick={() => void store.reset()}><RotateCcw/></button><button className="settings-button" onClick={() => setSettings(true)}><Settings/>Settings</button></div></header><nav className="mobile-tabs" aria-label="移动端面板切换">{(["skills", "chat", "events"] as MobileTab[]).map((item) => <button key={item} className={tab === item ? "active" : ""} onClick={() => setTab(item)}>{item}</button>)}</nav><div className="workspace-grid"><div className={`mobile-panel ${tab === "skills" ? "shown" : ""}`}><SkillsPanel/></div><div className={`mobile-panel center ${tab === "chat" ? "shown" : ""}`}><ChatPanel openSettings={() => setSettings(true)}/></div><div className={`mobile-panel ${tab === "events" ? "shown" : ""}`}><TimelinePanel/></div></div>{settings && <SettingsDialog open onOpenChange={setSettings}/>}<ApprovalDialog/></div></HarnessToolsProvider>; }
+15
View File
@@ -0,0 +1,15 @@
import { createContext, useContext, useSyncExternalStore } from "react";
import { HarnessClientStore, type ClientState } from "./store";
export const StoreContext = createContext<HarnessClientStore | null>(null);
export function useHarnessStore(): HarnessClientStore {
const store = useContext(StoreContext);
if (!store) throw new Error("Harness store is missing");
return store;
}
export function useHarnessState(): ClientState {
const store = useHarnessStore();
return useSyncExternalStore(store.subscribe, store.getSnapshot);
}
+20
View File
@@ -0,0 +1,20 @@
import { useMemo, useSyncExternalStore, type PropsWithChildren } from "react";
import { AssistantRuntimeProvider, useExternalStoreRuntime } from "@assistant-ui/react";
import { StoreContext } from "./HarnessContext";
import { HarnessClientStore } from "./store";
export function HarnessRuntime({ store, children }: PropsWithChildren<{ store: HarnessClientStore }>) {
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
const runtime = useExternalStoreRuntime({
messages: state.messages,
isRunning: state.isRunning,
isSendDisabled: !state.model.configured || !state.model.keyConfigured || state.connection !== "online",
setMessages: store.setMessages,
onNew: store.onNew,
onCancel: store.onCancel,
adapters: { attachments: store.attachmentAdapter },
convertMessage: (message) => message,
});
const value = useMemo(() => store, [store]);
return <StoreContext.Provider value={value}><AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider></StoreContext.Provider>;
}
+199
View File
@@ -0,0 +1,199 @@
import type { AppendMessage, Attachment, AttachmentAdapter, CompleteAttachment, PendingAttachment, ThreadMessageLike } from "@assistant-ui/react";
import { modelConnectionInputSchema, persistedModelConnectionSchema, type HarnessEvent, type ImageAttachment, type PersistedModelConnection, type RedactedModelConnection, type SkillCatalogItem } from "@agent-studio/shared";
export type ConnectionState = "connecting" | "online" | "reconnecting" | "offline";
export interface ClientState {
sessionId?: string;
messages: ThreadMessageLike[];
events: HarnessEvent[];
skills: SkillCatalogItem[];
model: RedactedModelConnection;
isRunning: boolean;
connection: ConnectionState;
error?: string | undefined;
}
interface LocalAttachment extends ImageAttachment { objectUrl: string }
type Listener = () => void;
const eventTypes: HarnessEvent["type"][] = ["session.started", "session.ended", "user.message", "assistant.delta", "assistant.completed", "skill.requested", "skill.loaded", "tool.requested", "tool.completed", "tool.failed", "approval.required", "approval.resolved", "attachment.uploaded", "attachment.rejected", "model.configured", "model.validation_failed", "run.started", "run.cancelled", "run.completed", "run.failed"];
const modelStorageKey = "agent-studio-mini:model-connection:v1";
export class HarnessClientStore {
private state: ClientState = { messages: [], events: [], skills: [], model: { configured: false, source: "missing", keyConfigured: false }, isRunning: false, connection: "connecting" };
private readonly listeners = new Set<Listener>();
private readonly seen = new Set<string>();
private readonly attachments = new Map<string, LocalAttachment>();
private source?: EventSource;
private lastSequence = 0;
private initializing: Promise<void> | undefined;
getSnapshot = (): ClientState => this.state;
subscribe = (listener: Listener): (() => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); };
private update(patch: Partial<ClientState>): void { this.state = { ...this.state, ...patch }; for (const listener of this.listeners) listener(); }
initialize(): Promise<void> {
if (this.state.sessionId) return Promise.resolve();
if (!this.initializing) {
this.initializing = this.initializeOnce().finally(() => { this.initializing = undefined; });
}
return this.initializing;
}
private async initializeOnce(): Promise<void> {
try {
// Session readiness must not depend on the optional Skills catalog request.
const created = await api<{ sessionId: string }>("/api/sessions", { method: "POST" });
this.update({ sessionId: created.sessionId, connection: "connecting", error: undefined });
this.connect();
await this.refreshModel();
await this.restorePersistedModel();
void this.refreshSkills();
} catch (error) {
this.update({ connection: "offline", error: `Session 初始化失败:${messageOf(error)}` });
}
}
private async refreshSkills(): Promise<void> {
try {
const result = await api<{ skills: SkillCatalogItem[] }>("/api/skills");
this.update({ skills: result.skills });
} catch (error) {
this.update({ error: `Skills Catalog 加载失败:${messageOf(error)}` });
}
}
private async requireSession(): Promise<string> {
if (!this.state.sessionId) await this.initialize();
if (!this.state.sessionId) {
throw new Error(this.state.error ?? "Session 初始化失败,请确认 Fastify 服务已启动后重试。");
}
return this.state.sessionId;
}
private connect(): void {
if (!this.state.sessionId) return;
this.source?.close();
const cursor = this.lastSequence ? `?afterSequence=${this.lastSequence}` : "";
const source = new EventSource(`/api/sessions/${this.state.sessionId}/events${cursor}`);
this.source = source;
source.onopen = () => this.update({ connection: "online", error: undefined });
source.onerror = () => this.update({ connection: source.readyState === EventSource.CLOSED ? "offline" : "reconnecting" });
for (const type of eventTypes) source.addEventListener(type, (raw) => this.reduce(JSON.parse((raw as MessageEvent<string>).data) as HarnessEvent));
source.addEventListener("stream.gap", () => { this.update({ error: "事件历史窗口已发生缺口;当前视图已从最早可用事件恢复。" }); });
}
reduce(event: HarnessEvent): void {
if (this.seen.has(event.eventId) || event.sequence <= this.lastSequence) return;
this.seen.add(event.eventId);
this.lastSequence = event.sequence;
const events = [...this.state.events, event].slice(-1_000);
let messages = this.state.messages;
let isRunning = this.state.isRunning;
if (event.type === "user.message") {
const attachmentItems = event.payload.attachmentIds.map((id) => this.attachments.get(id)).filter((item): item is LocalAttachment => Boolean(item));
messages = [...messages, { id: event.payload.messageId, role: "user", createdAt: new Date(event.timestamp), content: [{ type: "text", text: event.payload.text }, ...attachmentItems.map((item) => ({ type: "image" as const, image: item.objectUrl, filename: item.name }))], attachments: attachmentItems.map((item) => ({ id: item.attachmentId, type: "image" as const, name: item.name, contentType: item.mediaType, status: { type: "complete" as const }, content: [{ type: "image" as const, image: item.objectUrl, filename: item.name }] })) }];
} else if (event.type === "assistant.delta") {
messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => appendText(parts, event.payload.delta), { type: "running" }, event.runId);
} else if (event.type === "assistant.completed") {
messages = updateAssistant(messages, event.payload.messageId, event.timestamp, (parts) => parts, { type: "complete", reason: "stop" });
} else if (event.type === "tool.requested") {
const id = assistantIdForRun(messages, event.runId);
messages = updateAssistant(messages, id, event.timestamp, (parts) => upsertToolCall(parts, event.payload.toolCallId, event.payload.toolName, asArgs(event.payload.arguments)), { type: "running" }, event.runId);
} else if (event.type === "tool.completed" || event.type === "tool.failed") {
messages = updateTool(messages, event.payload.toolCallId, { result: event.type === "tool.completed" ? event.payload.result : { error: event.payload.error }, isError: event.type === "tool.failed" });
} else if (event.type === "approval.required") {
messages = updateTool(messages, event.payload.toolCallId, { approval: { id: event.payload.approvalId } });
} else if (event.type === "approval.resolved") {
const approval = event.payload.status === "approved" ? { id: event.payload.approvalId, approved: true } : event.payload.status === "denied" ? { id: event.payload.approvalId, approved: false, reason: "denied" } : { id: event.payload.approvalId, resolution: event.payload.status };
messages = updateTool(messages, event.payload.toolCallId, { approval });
} else if (event.type === "run.started") isRunning = true;
else if (event.type === "run.completed") isRunning = false;
else if (event.type === "run.cancelled") { isRunning = false; messages = finishLast(messages, { type: "incomplete", reason: "cancelled" }); }
else if (event.type === "run.failed") { isRunning = false; messages = finishLast(messages, { type: "incomplete", reason: "error", error: event.payload.message }); }
this.update({ events, messages, isRunning });
}
onNew = async (message: AppendMessage): Promise<void> => {
const sessionId = await this.requireSession();
const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
const attachmentIds = (message.attachments ?? []).map((item) => item.id).filter((id) => this.attachments.has(id));
if (attachmentIds.length > 0 && this.state.model.supportsImages !== true) throw new Error("当前模型不支持图片输入,请移除图片或切换 Vision 模型。");
await api(`/api/sessions/${sessionId}/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, attachmentIds }) });
};
onCancel = async (): Promise<void> => { if (this.state.sessionId) await api(`/api/sessions/${this.state.sessionId}/cancel`, { method: "POST" }); };
setMessages = (messages: readonly ThreadMessageLike[]): void => this.update({ messages: [...messages] });
async reset(): Promise<void> {
const old = this.state.sessionId;
this.source?.close();
if (old) await api(`/api/sessions/${old}`, { method: "DELETE" });
for (const item of this.attachments.values()) URL.revokeObjectURL(item.objectUrl);
this.attachments.clear(); this.seen.clear(); this.lastSequence = 0;
const created = await api<{ sessionId: string }>("/api/sessions", { method: "POST" });
this.state = { ...this.state, sessionId: created.sessionId, messages: [], events: [], model: { configured: false, source: "missing", keyConfigured: false }, isRunning: false, connection: "connecting", error: undefined };
for (const listener of this.listeners) listener();
await this.refreshModel(); await this.restorePersistedModel(); this.connect();
}
async refreshModel(): Promise<void> { if (!this.state.sessionId) return; const model = await api<RedactedModelConnection>(`/api/sessions/${this.state.sessionId}/model-config`); this.update({ model }); }
async saveModel(input: unknown): Promise<void> { const sessionId = await this.requireSession(); const parsed = modelConnectionInputSchema.parse(input); const model = await api<RedactedModelConnection>(`/api/sessions/${sessionId}/model-config`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(parsed) }); persistModel(withoutCredential(parsed)); this.update({ model }); }
async clearCredentials(): Promise<void> { const sessionId = await this.requireSession(); await api(`/api/sessions/${sessionId}/model-config/credentials`, { method: "DELETE" }); await this.refreshModel(); }
forgetPersistedModel(): void { removePersistedModel(); }
async testModel(): Promise<string> { const sessionId = await this.requireSession(); const result = await api<{ message: string }>(`/api/sessions/${sessionId}/model-config/test`, { method: "POST" }); return result.message; }
async resolveApproval(id: string, decision: "approve" | "deny"): Promise<void> { await api(`/api/approvals/${id}/${decision}`, { method: "POST" }); }
readonly attachmentAdapter: AttachmentAdapter = {
accept: "image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp",
add: async ({ file }): Promise<PendingAttachment> => {
const sessionId = await this.requireSession();
const form = new FormData(); form.append("file", file, file.name);
const item = await api<ImageAttachment>(`/api/sessions/${sessionId}/attachments`, { method: "POST", body: form });
const objectUrl = URL.createObjectURL(file);
this.attachments.set(item.attachmentId, { ...item, objectUrl });
return { id: item.attachmentId, type: "image", name: item.name, contentType: item.mediaType, file, status: { type: "requires-action", reason: "composer-send" }, content: [{ type: "image", image: objectUrl, filename: item.name }] };
},
remove: async (attachment: Attachment): Promise<void> => {
const item = this.attachments.get(attachment.id); if (!item || !this.state.sessionId) return;
await api(`/api/sessions/${this.state.sessionId}/attachments/${attachment.id}`, { method: "DELETE" }); URL.revokeObjectURL(item.objectUrl); this.attachments.delete(attachment.id);
},
send: async (attachment: PendingAttachment): Promise<CompleteAttachment> => {
const item = this.attachments.get(attachment.id); if (!item) throw new Error("Uploaded attachment is missing");
return { id: item.attachmentId, type: "image", name: item.name, contentType: item.mediaType, status: { type: "complete" }, content: [{ type: "image", image: item.objectUrl, filename: item.name }] };
},
};
private async restorePersistedModel(): Promise<void> {
if (!this.state.sessionId || this.state.model.configured) return;
const persisted = readPersistedModel();
if (!persisted) return;
try {
const model = await api<RedactedModelConnection>(`/api/sessions/${this.state.sessionId}/model-config`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(persisted) });
this.update({ model });
} catch {
removePersistedModel();
this.update({ error: "本地保存的模型配置已失效,请在 Settings 中重新配置。" });
}
}
}
type ContentParts = Exclude<ThreadMessageLike["content"], string>;
function updateAssistant(messages: ThreadMessageLike[], id: string, timestamp: string, change: (parts: ContentParts) => ContentParts, status: ThreadMessageLike["status"], runId?: string): ThreadMessageLike[] {
const index = messages.findIndex((item) => item.id === id);
if (index < 0) return [...messages, { id, role: "assistant", createdAt: new Date(timestamp), content: change([]), status, metadata: { custom: runId ? { runId } : {} } }];
return messages.map((item, current) => current === index ? { ...item, content: change(typeof item.content === "string" ? [{ type: "text", text: item.content }] : item.content), status } : item);
}
function appendText(parts: ContentParts, delta: string): ContentParts { const last = parts.at(-1); return last?.type === "text" ? [...parts.slice(0, -1), { ...last, text: last.text + delta }] : [...parts, { type: "text", text: delta }]; }
function upsertToolCall(parts: ContentParts, toolCallId: string, toolName: string, args: Record<string, string | number | boolean | null>): ContentParts {
const index = parts.findIndex((part) => part.type === "tool-call" && part.toolCallId === toolCallId);
if (index < 0) return [...parts, { type: "tool-call", toolCallId, toolName, args }];
return parts.map((part, current) => current === index && part.type === "tool-call" ? { ...part, toolName, args } : part);
}
function updateTool(messages: ThreadMessageLike[], toolCallId: string, patch: Record<string, unknown>): ThreadMessageLike[] { return messages.map((message) => ({ ...message, content: typeof message.content === "string" ? message.content : message.content.map((part) => part.type === "tool-call" && part.toolCallId === toolCallId ? { ...part, ...patch } : part) })); }
function assistantIdForRun(messages: ThreadMessageLike[], runId?: string): string { const existing = [...messages].reverse().find((item) => item.role === "assistant" && item.metadata?.custom?.runId === runId); return existing?.id ?? `assistant_${runId ?? crypto.randomUUID()}`; }
function finishLast(messages: ThreadMessageLike[], status: ThreadMessageLike["status"]): ThreadMessageLike[] { const index = messages.findLastIndex((item) => item.role === "assistant"); return index < 0 ? messages : messages.map((item, current) => current === index ? { ...item, status } : item); }
function asArgs(value: unknown): Record<string, string | number | boolean | null> { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string | number | boolean | null] => entry[1] === null || ["string", "number", "boolean"].includes(typeof entry[1]))); }
async function api<T = Record<string, unknown>>(url: string, init?: RequestInit): Promise<T> { const response = await fetch(url, { ...init, headers: { accept: "application/json", ...init?.headers } }); const data = await response.json().catch(() => ({})) as { error?: { message?: string; code?: string } }; if (!response.ok) throw new Error(data.error?.message ?? `Request failed (${response.status})`); return data as T; }
function messageOf(error: unknown): string { return error instanceof Error ? error.message : "Unexpected error"; }
function withoutCredential(input: unknown): PersistedModelConnection { const value = modelConnectionInputSchema.parse(input); return persistedModelConnectionSchema.parse(Object.fromEntries(Object.entries(value).filter(([key]) => key !== "apiKey"))); }
function readPersistedModel(): PersistedModelConnection | undefined { if (typeof window === "undefined") return undefined; try { const raw = window.localStorage.getItem(modelStorageKey); if (!raw) return undefined; if (raw.length > 16_384) { removePersistedModel(); return undefined; } const parsed = persistedModelConnectionSchema.safeParse(JSON.parse(raw)); if (!parsed.success) { removePersistedModel(); return undefined; } return parsed.data; } catch { removePersistedModel(); return undefined; } }
function persistModel(input: PersistedModelConnection): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(modelStorageKey, JSON.stringify(input)); } catch { /* Storage may be disabled or full; the active Session remains configured. */ } }
function removePersistedModel(): void { if (typeof window === "undefined") return; try { window.localStorage.removeItem(modelStorageKey); } catch { /* Storage may be unavailable. */ } }
@@ -0,0 +1,24 @@
import { Component, type ReactNode } from "react";
interface Props { children: ReactNode }
interface State { failed: boolean }
export class AppErrorBoundary extends Component<Props, State> {
override state: State = { failed: false };
static getDerivedStateFromError(): State {
return { failed: true };
}
override render(): ReactNode {
if (!this.state.failed) return this.props.children;
return <main className="fatal-fallback" role="alert">
<div>
<span className="eyebrow risk-text">UI RECOVERY</span>
<h1></h1>
<p>Agent Run Web Session</p>
<button className="accent" onClick={() => window.location.reload()}></button>
</div>
</main>;
}
}
@@ -0,0 +1,11 @@
import { useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { useHarnessState, useHarnessStore } from "../assistant/HarnessContext";
export function ApprovalDialog() {
const state = useHarnessState(); const store = useHarnessStore(); const [submitting, setSubmitting] = useState(false);
const pending = [...state.events].reverse().find((event) => event.type === "approval.required" && !state.events.some((candidate) => candidate.type === "approval.resolved" && candidate.payload.approvalId === event.payload.approvalId));
if (!pending || pending.type !== "approval.required") return null;
const act = async (decision: "approve" | "deny") => { setSubmitting(true); try { await store.resolveApproval(pending.payload.approvalId, decision); } finally { setSubmitting(false); } };
return <Dialog.Root open><Dialog.Portal><Dialog.Overlay className="dialog-overlay approval-overlay"/><Dialog.Content className="approval-dialog" onEscapeKeyDown={(event) => event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()} aria-describedby="approval-risk"><span className="eyebrow risk-text">HUMAN APPROVAL REQUIRED</span><Dialog.Title>稿</Dialog.Title><Dialog.Description id="approval-risk">Tool <code>{pending.payload.toolName}</code> Session <code>report.md</code> </Dialog.Description><pre>{JSON.stringify(pending.payload.arguments, null, 2).slice(0, 1500)}</pre><div className="dialog-actions"><button className="danger" disabled={submitting} onClick={() => void act("deny")}></button><button className="accent" disabled={submitting} onClick={() => void act("approve")}></button></div></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
+48
View File
@@ -0,0 +1,48 @@
import { useState } from "react";
import { AttachmentPrimitive, ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAui, useAuiState } from "@assistant-ui/react";
import { ImagePlus, Send, Square, X } from "lucide-react";
import * as Dialog from "@radix-ui/react-dialog";
import { ToolFallback } from "./ToolUIs";
import { useHarnessState } from "../assistant/HarnessContext";
const demoPrompt = "请使用 ads-analysis Skill,分析账户 demo-account 最近 7 天数据,找出异常 Campaign,并输出账户总结、关键指标和 3 条优化建议。最后使用 report-writer Skill 生成一份报告草稿。";
function TextPart({ text }: { text: string }) { return <div className="message-text">{text}</div>; }
function ImagePart({ image, filename }: { image: string; filename?: string }) { return <ImagePreview src={image} name={filename ?? "图片"}/>; }
const partComponents = { Text: TextPart, Image: ImagePart, tools: { Fallback: ToolFallback } };
function UserMessage() { return <MessagePrimitive.Root className="message user"><div className="role">You</div><MessagePrimitive.Content components={partComponents}/><MessagePrimitive.Attachments>{() => null}</MessagePrimitive.Attachments></MessagePrimitive.Root>; }
function AssistantMessage() { return <MessagePrimitive.Root className="message assistant"><div className="role">Agent</div><MessagePrimitive.Content components={partComponents}/><MessagePrimitive.Error/></MessagePrimitive.Root>; }
function ImagePreview({ src, name }: { src: string; name: string }) {
return <Dialog.Root><Dialog.Trigger asChild><button className="image-preview" aria-label={`预览 ${name}`}><img src={src} alt={name}/></button></Dialog.Trigger><Dialog.Portal><Dialog.Overlay className="dialog-overlay"/><Dialog.Content className="image-dialog" aria-describedby={undefined}><Dialog.Title>{name}</Dialog.Title><img src={src} alt={name}/><Dialog.Close className="icon-button" aria-label="关闭预览"><X/></Dialog.Close></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
function ComposerAttachment() {
const preview = useAuiState((state) => {
const part = state.attachment.content?.find((item) => item.type === "image");
return part?.type === "image" ? part.image : undefined;
});
return <AttachmentPrimitive.Root className="composer-attachment">{preview ? <img src={preview} alt=""/> : <span aria-hidden="true">IMG</span>}<AttachmentPrimitive.Name/><AttachmentPrimitive.Remove className="remove-attachment" aria-label="移除图片"><X size={14}/></AttachmentPrimitive.Remove></AttachmentPrimitive.Root>;
}
export function ChatPanel({ openSettings }: { openSettings: () => void }) {
const state = useHarnessState();
const aui = useAui();
const [dragging, setDragging] = useState(false);
const modelReady = state.model.configured && state.model.keyConfigured;
const fillDemo = () => aui.composer().setText(demoPrompt);
return <main className="chat-panel" aria-label="Streaming Chat">
<div className="chat-toolbar"><div><strong>{state.isRunning ? "Agent 正在运行" : "准备就绪"}</strong><span className={`connection ${state.connection}`}>{state.connection}</span></div><button className="text-button" onClick={fillDemo}> Demo Prompt</button></div>
{!modelReady && <button className="config-banner" onClick={openSettings}>{state.model.configured ? "模型配置已恢复,请在 Settings 重新输入 API Key。" : "尚未配置模型。打开 Settings 后才能发送消息。"}</button>}
{state.error && <div className="error-banner" role="alert">{state.error}</div>}
<ThreadPrimitive.Root className="thread-root"><ThreadPrimitive.Viewport className="thread-viewport"><ThreadPrimitive.Empty><div className="empty-state"><div className="orb">π</div><h2>Agent Loop </h2><p> Demo Prompt Skill Tool </p></div></ThreadPrimitive.Empty><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }}/><ThreadPrimitive.ViewportFooter className="composer-footer">
<ComposerPrimitive.Root className={`composer ${dragging ? "dragging" : ""}`}>
<ComposerPrimitive.AttachmentDropzone className="dropzone" onDragEnter={() => setDragging(true)} onDragLeave={() => setDragging(false)} onDrop={() => setDragging(false)}>
<ComposerPrimitive.Attachments components={{ Attachment: ComposerAttachment }}/>
<ComposerPrimitive.Input className="composer-input" placeholder={modelReady ? "给 Agent Studio Mini 发送任务…" : state.model.configured ? "请重新输入 API Key" : "请先配置模型连接"} aria-label="消息输入"/>
<div className="composer-actions"><ComposerPrimitive.AddAttachment className="icon-button" aria-label="添加图片" title="选择、拖拽或粘贴 JPEG/PNG/WebP"><ImagePlus/></ComposerPrimitive.AddAttachment><span className="composer-hint"> 4 · 5 MiB/</span>{state.isRunning ? <ComposerPrimitive.Cancel className="send-button stop" aria-label="停止运行"><Square size={16}/></ComposerPrimitive.Cancel> : <ComposerPrimitive.Send className="send-button" aria-label="发送消息"><Send size={17}/></ComposerPrimitive.Send>}</div>
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
</ThreadPrimitive.ViewportFooter></ThreadPrimitive.Viewport></ThreadPrimitive.Root>
<div className="sr-live" aria-live="polite">{state.isRunning ? "" : state.messages.at(-1)?.role === "assistant" ? "Assistant 回复已完成" : ""}</div>
</main>;
}
@@ -0,0 +1,21 @@
import { useState, type FormEvent } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { Eye, EyeOff, X } from "lucide-react";
import { useHarnessState, useHarnessStore } from "../assistant/HarnessContext";
const presets = {
openai: { providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", modelId: "gpt-5.4", supportsImages: true },
anthropic: { providerId: "anthropic", api: "anthropic-messages", baseUrl: "https://api.anthropic.com", modelId: "claude-sonnet-4-6", supportsImages: true },
compatible: { providerId: "openai-compatible", api: "openai-completions", baseUrl: "https://example.com/v1", modelId: "your-model-id", supportsImages: false },
} as const;
type ApiType = "openai-responses" | "openai-completions" | "anthropic-messages";
interface FormState { providerId: string; api: ApiType; baseUrl: string; apiKey: string; modelId: string; displayName: string; supportsImages: boolean }
export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const store = useHarnessStore(); const state = useHarnessState(); const [showKey, setShowKey] = useState(false); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState("");
const [form, setForm] = useState<FormState>(() => ({ providerId: state.model.providerId ?? "openai", api: state.model.api ?? "openai-responses", baseUrl: state.model.baseUrl ?? "https://api.openai.com/v1", apiKey: "", modelId: state.model.modelId ?? "gpt-5.4", displayName: state.model.displayName ?? "", supportsImages: state.model.supportsImages ?? true }));
const applyPreset = (name: keyof typeof presets) => setForm((value) => ({ ...value, ...presets[name], apiKey: "", displayName: "" }));
const submit = async (event: FormEvent) => { event.preventDefault(); setBusy(true); setNotice(""); try { await store.saveModel({ providerId: form.providerId, api: form.api, modelId: form.modelId, supportsImages: form.supportsImages, ...(form.apiKey ? { apiKey: form.apiKey } : {}), ...(form.baseUrl ? { baseUrl: form.baseUrl } : {}), ...(form.displayName ? { displayName: form.displayName } : {}) }); setForm((value) => ({ ...value, apiKey: "" })); setNotice(store.getSnapshot().model.credentialStorage === "encrypted" ? "模型配置已保存;API Key 已由服务器加密持久化。" : "模型配置已保存;当前服务器未启用加密凭证持久化,API Key 仅保存在 Session 内存。"); } catch (error) { setNotice(error instanceof Error ? error.message : "保存失败"); } finally { setBusy(false); } };
const storageLabel = state.model.credentialStorage === "encrypted" ? "服务端加密" : state.model.credentialStorage === "environment" ? "环境变量" : state.model.credentialStorage === "memory" ? "Session 内存" : "无";
return <Dialog.Root open={open} onOpenChange={(next) => { if (!busy) onOpenChange(next); }}><Dialog.Portal><Dialog.Overlay className="dialog-overlay"/><Dialog.Content className="settings-dialog" aria-describedby="settings-description"><div className="dialog-header"><div><span className="eyebrow">SESSION RUNTIME</span><Dialog.Title></Dialog.Title><Dialog.Description id="settings-description"> .env API Key Web Storage使 AES-256-GCM </Dialog.Description></div><Dialog.Close className="icon-button" aria-label="关闭设置"><X/></Dialog.Close></div><div className="presets"><button onClick={() => applyPreset("openai")}>OpenAI</button><button onClick={() => applyPreset("anthropic")}>Anthropic</button><button onClick={() => applyPreset("compatible")}>OpenAI-compatible</button></div><form onSubmit={(event) => void submit(event)}><div className="form-grid"><label>Provider ID<input value={form.providerId} onChange={(e) => setForm({ ...form, providerId: e.target.value })} required/></label><label>API<select value={form.api} onChange={(e) => setForm({ ...form, api: e.target.value as ApiType })}><option value="openai-responses">openai-responses</option><option value="openai-completions">openai-completions</option><option value="anthropic-messages">anthropic-messages</option></select></label><label className="span-2">Base URL<input value={form.baseUrl} onChange={(e) => setForm({ ...form, baseUrl: e.target.value })} placeholder="https://api.example.com/v1"/></label><label>Model ID<input value={form.modelId} onChange={(e) => setForm({ ...form, modelId: e.target.value })} required/></label><label>Display Name<input value={form.displayName} onChange={(e) => setForm({ ...form, displayName: e.target.value })}/></label><label className="span-2">API Key<div className="password-field"><input type={showKey ? "text" : "password"} autoComplete="off" value={form.apiKey} onChange={(e) => setForm({ ...form, apiKey: e.target.value })} placeholder={state.model.keyConfigured ? state.model.keyHint ?? "已配置" : "输入 API Key"}/><button type="button" className="icon-button" onClick={() => setShowKey(!showKey)} aria-label={showKey ? "隐藏 API Key" : "显示 API Key"}>{showKey ? <EyeOff/> : <Eye/>}</button></div></label><label className="check span-2"><input type="checkbox" checked={form.supportsImages} onChange={(e) => setForm({ ...form, supportsImages: e.target.checked })}/> Pi Model </label></div>{notice && <p className="notice" role="status">{notice}</p>}<div className="config-status"><span>{state.model.source}</span><span>{state.model.keyConfigured ? state.model.keyHint ?? "已配置" : "未配置"}</span><span>{storageLabel}</span></div><div className="dialog-actions"><button type="button" className="danger" disabled={busy || state.isRunning} onClick={() => void store.clearCredentials()}></button><button type="button" disabled={busy} onClick={() => { store.forgetPersistedModel(); setNotice("已清除浏览器本地保存的模型配置;当前 Session 不受影响。"); }}></button><button type="button" disabled={busy} onClick={() => { setBusy(true); void store.testModel().then(setNotice).catch((error: unknown) => setNotice(error instanceof Error ? error.message : "测试失败")).finally(() => setBusy(false)); }}></button><button type="submit" className="accent" disabled={busy || state.isRunning}></button></div></form></Dialog.Content></Dialog.Portal></Dialog.Root>;
}
+7
View File
@@ -0,0 +1,7 @@
import { useHarnessState } from "../assistant/HarnessContext";
export function SkillsPanel() {
const { skills, events, connection } = useHarnessState();
const active = new Set(events.filter((event) => event.type === "skill.requested" || event.type === "skill.loaded").map((event) => event.payload.name));
return <aside className="side-panel skills-panel" aria-label="Skills Catalog"><div className="panel-heading"><span className="eyebrow">RESOURCES</span><h2>Skills Catalog</h2><p> Pi read </p></div>{connection === "connecting" && <div className="panel-state"> Skills</div>}{skills.length === 0 && connection !== "connecting" && <div className="panel-state"> Skill</div>}<div className="skill-list">{skills.map((skill) => { const level = skill.diagnostics.some((item) => item.severity === "error") ? "error" : skill.diagnostics.length ? "warning" : "valid"; return <article key={`${skill.name}:${skill.filePath}`} className={`skill-item ${active.has(skill.name) ? "active" : ""}`}><div className="skill-title"><strong>{skill.name}</strong><span className={`diagnostic ${level}`}>{level}</span></div><p>{skill.description}</p><small>{skill.source} · {skill.filePath}</small>{skill.diagnostics.map((item, index) => <div className="diagnostic-message" key={index}>{item.message}</div>)}</article>; })}</div></aside>;
}
+12
View File
@@ -0,0 +1,12 @@
import { useEffect, useRef, useState } from "react";
import { useHarnessState } from "../assistant/HarnessContext";
import type { HarnessEvent } from "@agent-studio/shared";
type Filter = "all" | "run" | "skill" | "tool" | "approval" | "model";
export function TimelinePanel() {
const { events } = useHarnessState(); const [filter, setFilter] = useState<Filter>("all"); const viewport = useRef<HTMLDivElement>(null); const atBottom = useRef(true);
const visible = events.filter((event) => filter === "all" || event.type.startsWith(`${filter}.`) || (filter === "tool" && event.type.startsWith("attachment.")));
useEffect(() => { if (atBottom.current) viewport.current?.scrollTo({ top: viewport.current.scrollHeight }); }, [visible.length]);
return <aside className="side-panel timeline-panel" aria-label="Event Timeline"><div className="panel-heading"><span className="eyebrow">HARNESS FACTS</span><h2>Event Timeline</h2><p>Pi Skill Session sequence </p></div><div className="filters" role="group" aria-label="事件筛选">{(["all", "run", "skill", "tool", "approval", "model"] as Filter[]).map((item) => <button key={item} className={filter === item ? "selected" : ""} onClick={() => setFilter(item)}>{item}</button>)}</div><div className="timeline" ref={viewport} onScroll={(event) => { const node = event.currentTarget; atBottom.current = node.scrollHeight - node.scrollTop - node.clientHeight < 48; }}>{visible.length === 0 && <div className="panel-state"> Harness </div>}{visible.map((event) => <EventRow key={event.eventId} event={event}/>)}</div></aside>;
}
function EventRow({ event }: { event: HarnessEvent }) { const [open, setOpen] = useState(false); return <article className={`event-row ${event.type.startsWith("approval.") ? "risk" : ""}`}><button onClick={() => setOpen(!open)} aria-expanded={open}><span className="sequence">#{event.sequence}</span><span><strong>{event.type}</strong><small>{new Date(event.timestamp).toLocaleTimeString()}</small></span></button>{open && <pre>{JSON.stringify(event.payload, null, 2)}</pre>}</article>; }
+48
View File
@@ -0,0 +1,48 @@
import { useState } from "react";
import { AuiConfig, AuiProvider, defineToolkit, Tools, useAui, type Toolkit, type ToolCallMessagePartProps } from "@assistant-ui/react";
import type { PropsWithChildren } from "react";
import { z } from "zod";
import { useHarnessStore } from "../assistant/HarnessContext";
type JsonObject = Record<string, unknown>;
function stateLabel(props: ToolCallMessagePartProps<JsonObject, unknown>): string {
if (props.isError) return "执行失败";
if (props.approval?.resolution) return props.approval.resolution === "expired" ? "审批超时" : "已取消";
if (props.approval?.approved === false) return "已拒绝";
if (props.approval?.approved === true && props.result === undefined) return "已批准 · 执行中";
if (props.approval && props.approval.approved === undefined) return "等待审批";
if (props.result !== undefined) return "完成";
return "执行中";
}
function ApprovalActions({ approval }: Pick<ToolCallMessagePartProps<JsonObject, unknown>, "approval">) {
const store = useHarnessStore();
const [submitting, setSubmitting] = useState(false);
if (!approval || approval.approved !== undefined || approval.resolution) return null;
const act = async (decision: "approve" | "deny") => { setSubmitting(true); try { await store.resolveApproval(approval.id, decision); } finally { setSubmitting(false); } };
return <div className="approval-actions"><button disabled={submitting} className="danger" onClick={() => void act("deny")}></button><button disabled={submitting} className="accent" onClick={() => void act("approve")}></button></div>;
}
export function MockAdsMetricsUI(props: ToolCallMessagePartProps<JsonObject, unknown>) {
const result = asRecord(asRecord(props.result).details ?? props.result);
return <section className="tool-card" data-tool="mock_ads_metrics"><header><span>mock_ads_metrics</span><span className="status">{stateLabel(props)}</span></header><p className="muted"> {String(props.args?.accountId ?? "—")} · {String(props.args?.days ?? "—")} </p>{Object.keys(result).length > 0 && <div className="metrics"><Metric label="Spend" value={result.spend}/><Metric label="CTR" value={result.ctr} suffix="%"/><Metric label="CPA" value={result.cpa}/><Metric label="ROAS" value={result.roas}/></div>}</section>;
}
export function SaveReportDraftUI(props: ToolCallMessagePartProps<JsonObject, unknown>) {
const content = typeof props.args?.content === "string" ? props.args.content : "";
return <section className="tool-card risk" data-tool="save_report_draft"><header><span>save_report_draft</span><span className="status">{stateLabel(props)}</span></header><p> Session <code>report.md</code></p><p className="summary">{content.slice(0, 180)}{content.length > 180 ? "…" : ""}</p>{props.approval && <ApprovalActions approval={props.approval}/>}</section>;
}
export function ToolFallback(props: ToolCallMessagePartProps) { return <section className="tool-card"><header><span>{props.toolName}</span><span className="status">{stateLabel(props)}</span></header><pre>{JSON.stringify(props.args ?? {}, null, 2)}</pre></section>; }
function Metric({ label, value, suffix = "" }: { label: string; value: unknown; suffix?: string }) { return <div><span>{label}</span><strong>{typeof value === "number" ? value.toLocaleString() : "—"}{suffix}</strong></div>; }
function asRecord(value: unknown): JsonObject { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {}; }
const authoredToolkit = defineToolkit({
mock_ads_metrics: { description: "显示服务端广告指标工具结果", parameters: z.object({ accountId: z.string(), days: z.number() }), render: MockAdsMetricsUI },
save_report_draft: { description: "显示服务端报告保存审批与结果", parameters: z.object({ content: z.string(), title: z.string().optional() }), render: SaveReportDraftUI },
});
const harnessToolkit = authoredToolkit as Toolkit;
export function HarnessToolsProvider({ children }: PropsWithChildren) {
const parent = useAui();
const config = AuiConfig({ tools: Tools({ toolkit: harnessToolkit }) });
return <AuiProvider extends={parent} config={config}>{children}</AuiProvider>;
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { HarnessRuntime } from "./assistant/HarnessRuntime";
import { HarnessClientStore } from "./assistant/store";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
import "./styles/app.css";
const store = new HarnessClientStore();
void store.initialize();
createRoot(document.getElementById("root")!).render(<StrictMode><AppErrorBoundary><HarnessRuntime store={store}><App/></HarnessRuntime></AppErrorBoundary></StrictMode>);
+106
View File
@@ -0,0 +1,106 @@
:root {
color-scheme: dark;
--black: #111111; --cyan: #25f4ee; --pink: #fe2c55;
--surface-0: #0c0d0f; --surface-1: #131519; --surface-2: #1a1d22; --surface-3: #23272e;
--border: #2c3139; --border-strong: #3d444f; --text: #f3f5f7; --muted: #929aa6;
--accent: var(--cyan); --danger: var(--pink); --focus: #a8fffc;
--radius: 10px; --shadow: 0 18px 50px rgb(0 0 0 / .34);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--surface-0); color: var(--text); font-synthesis: none;
}
* { box-sizing: border-box; }
html, body, #root { width: 100%; min-width: 320px; height: 100%; margin: 0; overflow: hidden; }
body { background: radial-gradient(circle at 50% -20%, #183136 0, var(--surface-0) 34%); }
button, input, select, textarea { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
button:disabled { opacity: .46; cursor: not-allowed; }
.app-shell { display: grid; grid-template-rows: 64px 1fr; height: 100%; }
.topbar { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 16px; border-bottom: 1px solid var(--border); background: rgb(12 13 15 / .9); backdrop-filter: blur(14px); z-index: 10; }
.brand, .session-summary, .brand > div, .chat-toolbar > div { display: flex; align-items: center; gap: 10px; min-width: 0; }
.brand > div { align-items: flex-start; flex-direction: column; gap: 1px; }
.brand strong { letter-spacing: -.02em; }
.brand small { color: var(--muted); font-size: 11px; }
.brand-mark { width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid #267a7a; border-radius: 10px; color: var(--cyan); background: #132426; box-shadow: inset 0 0 16px rgb(37 244 238 / .08); }
.session-summary { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); }
.run-dot { width: 8px; height: 8px; border-radius: 50%; background: #59616d; }
.run-dot.running { background: var(--cyan); box-shadow: 0 0 12px var(--cyan); animation: pulse 1.4s ease-in-out infinite; }
.model-badge, .vision-badge { border: 1px solid var(--border-strong); border-radius: 999px; padding: 5px 9px; color: var(--text); background: var(--surface-2); }
.vision-badge { color: var(--cyan); border-color: #267a7a; }
.settings-button, .send-button, .text-button, .accent, .danger, .dialog-actions button, .presets button { min-height: 36px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-2); padding: 0 12px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }
.settings-button svg, .icon-button svg { width: 17px; height: 17px; }
.settings-button:hover, .text-button:hover, .dialog-actions button:hover, .presets button:hover { background: var(--surface-3); border-color: #59616d; }
.icon-button { width: 40px; height: 40px; display: inline-grid; place-items: center; border: 0; border-radius: 8px; background: transparent; cursor: pointer; }
.icon-button:hover { background: var(--surface-3); }
.workspace-grid { min-height: 0; display: grid; grid-template-columns: minmax(220px, 280px) minmax(420px, 1fr) minmax(260px, 340px); }
.mobile-panel { min-height: 0; min-width: 0; }
.side-panel { height: 100%; min-height: 0; display: flex; flex-direction: column; background: rgb(19 21 25 / .88); }
.skills-panel { border-right: 1px solid var(--border); }
.timeline-panel { border-left: 1px solid var(--border); }
.panel-heading { padding: 20px 16px 14px; border-bottom: 1px solid var(--border); }
.panel-heading h2 { margin: 4px 0 7px; font-size: 16px; }
.panel-heading p { margin: 0; color: var(--muted); line-height: 1.45; font-size: 12px; }
.eyebrow { font-size: 10px; font-weight: 800; letter-spacing: .13em; color: var(--cyan); }
.skill-list, .timeline { min-height: 0; overflow: auto; padding: 8px; }
.skill-item { padding: 13px 10px; border-left: 2px solid transparent; border-bottom: 1px solid var(--border); }
.skill-item.active { border-left-color: var(--cyan); background: linear-gradient(90deg, rgb(37 244 238 / .08), transparent); }
.skill-title { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.skill-title strong { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; }
.skill-item p { margin: 7px 0; color: #cbd0d6; line-height: 1.45; font-size: 12px; }
.skill-item small { color: var(--muted); word-break: break-word; }
.diagnostic { border-radius: 999px; padding: 2px 6px; font-size: 9px; text-transform: uppercase; border: 1px solid; }
.diagnostic.valid { color: var(--cyan); border-color: #267a7a; }.diagnostic.warning { color: #ffd166; border-color: #806c2d; }.diagnostic.error { color: var(--pink); border-color: #8d2940; }
.diagnostic-message { color: #ffb4c4; font-size: 11px; margin-top: 8px; }
.panel-state { padding: 20px; color: var(--muted); font-size: 13px; text-align: center; }
.chat-panel { height: 100%; min-height: 0; display: grid; grid-template-rows: auto auto auto 1fr; background: var(--surface-0); }
.chat-toolbar { min-height: 50px; display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; border-bottom: 1px solid var(--border); font-size: 12px; }
.connection { text-transform: capitalize; color: var(--muted); }.connection.online { color: var(--cyan); }.connection.reconnecting { color: #ffd166; }.connection.offline { color: var(--pink); }
.text-button { min-height: 32px; color: var(--cyan); }
.config-banner, .error-banner { margin: 10px 16px 0; border-radius: 8px; padding: 10px 12px; text-align: left; }
.config-banner { border: 1px solid #756326; background: #28230f; color: #ffe395; cursor: pointer; }.error-banner { border: 1px solid #7e293b; background: #2b1118; color: #ffbdca; }
.thread-root, .thread-viewport { min-height: 0; height: 100%; }
.thread-viewport { overflow-y: auto; padding: 16px 0 0; scroll-padding-bottom: 170px; }
.empty-state { min-height: 52vh; display: grid; place-items: center; align-content: center; text-align: center; padding: 32px; color: var(--muted); }
.empty-state h2 { margin: 16px 0 6px; color: var(--text); font-size: 22px; }.empty-state p { max-width: 460px; line-height: 1.55; }
.orb { width: 70px; height: 70px; display: grid; place-items: center; border-radius: 50%; color: var(--cyan); font: 700 28px Georgia, serif; background: radial-gradient(circle at 35% 30%, #27535a, #121c20 62%); border: 1px solid #2e6f74; box-shadow: 0 0 50px rgb(37 244 238 / .12); }
.message { width: min(760px, calc(100% - 32px)); margin: 0 auto 22px; }
.message .role { margin-bottom: 7px; color: var(--muted); font: 700 10px ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; }
.message.user { display: grid; justify-items: end; }.message.user .role { text-align: right; }
.message-text { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.62; }
.user .message-text { max-width: min(86%, 620px); padding: 11px 14px; border-radius: 14px 14px 3px 14px; background: #20252b; border: 1px solid #333a44; }
.assistant .message-text { color: #e7eaee; }
.image-preview { border: 1px solid var(--border-strong); padding: 0; border-radius: 9px; background: var(--surface-2); overflow: hidden; cursor: zoom-in; margin: 8px 5px 0 0; }
.image-preview img { display: block; width: 160px; height: 110px; object-fit: cover; }
.tool-card { margin: 12px 0; border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--surface-1); overflow: hidden; }
.tool-card.risk { border-color: #713044; box-shadow: inset 3px 0 0 var(--pink); }
.tool-card header { padding: 10px 12px; display: flex; justify-content: space-between; background: var(--surface-2); font: 600 12px ui-monospace, monospace; }
.tool-card .status { color: var(--cyan); }.tool-card.risk .status { color: #ff91a8; }.tool-card p, .tool-card pre { margin: 0; padding: 10px 12px; font-size: 12px; line-height: 1.5; }.tool-card pre { overflow: auto; color: #cbd0d6; }.tool-card .summary { max-height: 95px; overflow: hidden; color: var(--muted); }
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--border); }.metrics div { padding: 10px; border-right: 1px solid var(--border); }.metrics span, .metrics strong { display: block; }.metrics span { color: var(--muted); font-size: 10px; }.metrics strong { margin-top: 4px; font-size: 14px; }
.approval-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border); }
.accent { color: #071011; background: var(--cyan); border-color: var(--cyan); font-weight: 750; }.accent:hover { background: #66fffa !important; }.danger { color: #ffb3c3; border-color: #7e2d40; background: #291319; }.danger:hover { border-color: var(--pink) !important; background: #36151e !important; }
.composer-footer { position: sticky; bottom: 0; padding: 16px; background: linear-gradient(transparent, var(--surface-0) 18%); }
.composer { width: min(780px, 100%); margin: 0 auto; border: 1px solid var(--border-strong); border-radius: 14px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
.composer.dragging { border-color: var(--cyan); box-shadow: 0 0 0 3px rgb(37 244 238 / .12); }
.dropzone { padding: 8px; }
.composer-input { display: block; width: 100%; min-height: 62px; max-height: 160px; resize: none; border: 0; padding: 10px; background: transparent; color: var(--text); outline: none !important; }
.composer-actions { display: flex; align-items: center; gap: 5px; }.composer-hint { flex: 1; color: var(--muted); font-size: 10px; }.send-button { background: var(--cyan); color: #061112; border-color: var(--cyan); font-weight: 750; }.send-button.stop { background: var(--surface-2); color: #ff9caf; border-color: #7e2d40; }
.composer-attachment { width: 92px; min-height: 76px; position: relative; display: inline-grid; place-items: center; margin: 4px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface-2); font-size: 10px; }
.composer-attachment img { width: 100%; height: 54px; object-fit: cover; }.remove-attachment { position: absolute; top: 3px; right: 3px; width: 25px; height: 25px; border: 0; border-radius: 50%; display: grid; place-items: center; background: rgb(0 0 0 / .72); cursor: pointer; }
.filters { padding: 8px; display: flex; gap: 5px; flex-wrap: wrap; border-bottom: 1px solid var(--border); }.filters button { min-height: 29px; padding: 0 8px; border-radius: 6px; border: 1px solid var(--border); background: transparent; color: var(--muted); font-size: 10px; cursor: pointer; }.filters button.selected { color: var(--cyan); border-color: #267a7a; background: #122326; }
.event-row { position: relative; border-left: 1px solid var(--border-strong); padding: 0 0 8px 8px; }.event-row.risk { border-left-color: var(--pink); }.event-row > button { width: 100%; min-height: 44px; display: grid; grid-template-columns: 35px 1fr; gap: 6px; align-items: center; text-align: left; border: 0; background: transparent; border-radius: 7px; cursor: pointer; }.event-row > button:hover { background: var(--surface-2); }.event-row .sequence { color: var(--muted); font: 10px ui-monospace, monospace; }.event-row strong, .event-row small { display: block; }.event-row strong { font: 600 11px ui-monospace, monospace; }.event-row small { margin-top: 3px; color: var(--muted); font-size: 9px; }.event-row pre { margin: 3px 0 8px 36px; max-height: 200px; overflow: auto; padding: 8px; border-radius: 6px; background: #0b0c0e; color: #bac1ca; font-size: 9px; white-space: pre-wrap; }
.dialog-overlay { position: fixed; inset: 0; z-index: 30; background: rgb(2 3 4 / .72); backdrop-filter: blur(5px); animation: fade .15s ease; }
.settings-dialog, .approval-dialog, .image-dialog { position: fixed; z-index: 31; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(620px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; border: 1px solid var(--border-strong); border-radius: 14px; background: #15171b; box-shadow: 0 30px 100px rgb(0 0 0 / .65); padding: 20px; }
.dialog-header { display: flex; justify-content: space-between; gap: 16px; }.dialog-header h2, .approval-dialog h2 { margin: 5px 0 7px; }.dialog-header p, .approval-dialog > p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.presets { display: flex; gap: 8px; margin: 18px 0 14px; }.presets button { flex: 1; font-size: 12px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }.form-grid label { display: grid; gap: 6px; color: #c2c7ce; font-size: 11px; }.form-grid input, .form-grid select { width: 100%; min-height: 42px; border: 1px solid var(--border-strong); border-radius: 7px; padding: 0 10px; color: var(--text); background: #101216; }.span-2 { grid-column: span 2 !important; }.password-field { position: relative; }.password-field input { padding-right: 48px; }.password-field .icon-button { position: absolute; right: 1px; top: 1px; }.check { grid-template-columns: auto 1fr !important; align-items: center; min-height: 40px; }.check input { width: 18px; min-height: 18px; accent-color: var(--cyan); }
.notice { padding: 10px; border-radius: 7px; background: var(--surface-2); font-size: 12px; }.config-status { display: flex; gap: 16px; color: var(--muted); font-size: 11px; margin: 12px 0; }.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; padding-top: 14px; border-top: 1px solid var(--border); }.dialog-actions .danger { margin-right: auto; }
.approval-dialog { width: min(500px, calc(100vw - 32px)); border-color: #8b3045; }.approval-dialog pre { max-height: 200px; overflow: auto; margin: 16px 0; padding: 10px; border-radius: 8px; background: #0c0d0f; color: #cbd0d6; font-size: 10px; white-space: pre-wrap; }.risk-text { color: var(--pink); }
.image-dialog { width: min(900px, calc(100vw - 32px)); padding: 12px; }.image-dialog h2 { padding: 0 36px 8px 5px; margin: 0; font-size: 14px; }.image-dialog img { display: block; max-width: 100%; max-height: calc(100vh - 100px); margin: auto; }.image-dialog .icon-button { position: absolute; top: 4px; right: 4px; }
.sr-live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }.mobile-tabs { display: none; }
.fatal-fallback { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: var(--surface-0); color: var(--text); }.fatal-fallback > div { width: min(520px, 100%); border: 1px solid #8b3045; border-radius: 14px; padding: 24px; background: var(--surface-1); box-shadow: var(--shadow); }.fatal-fallback h1 { margin: 8px 0; font-size: 22px; }.fatal-fallback p { margin: 0 0 18px; color: var(--muted); line-height: 1.6; }
@keyframes pulse { 50% { opacity: .45; transform: scale(.8); } } @keyframes fade { from { opacity: 0; } }
@media (max-width: 1050px) { .workspace-grid { grid-template-columns: 230px minmax(380px, 1fr); }.workspace-grid > .mobile-panel:last-child { display: none; }.session-summary > span:nth-child(2) { display: none; } }
@media (max-width: 720px) {
html, body, #root { overflow: hidden; }.app-shell { grid-template-rows: 58px 44px 1fr; }.brand small, .model-badge, .vision-badge, .session-summary > .run-dot { display: none; }.topbar { padding: 0 10px; }.settings-button { width: 42px; padding: 0; font-size: 0; }.settings-button svg { width: 18px; }.mobile-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--border); background: var(--surface-1); }.mobile-tabs button { border: 0; background: transparent; color: var(--muted); text-transform: capitalize; }.mobile-tabs button.active { color: var(--cyan); box-shadow: inset 0 -2px var(--cyan); }.workspace-grid { display: block; min-height: 0; }.mobile-panel { display: none !important; height: 100%; }.mobile-panel.shown { display: block !important; }.side-panel { border: 0; }.chat-toolbar { padding: 6px 10px; }.text-button { padding: 0 8px; }.composer-footer { padding: 10px; }.message { width: calc(100% - 20px); }.metrics { grid-template-columns: repeat(2, 1fr); }.form-grid { grid-template-columns: 1fr; }.span-2 { grid-column: span 1 !important; }.presets { overflow-x: auto; }.presets button { flex: 0 0 auto; }.dialog-actions { flex-wrap: wrap; }.dialog-actions button { flex: 1; }.dialog-actions .danger { margin: 0; }.composer-hint { display: none; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } }
+48
View File
@@ -0,0 +1,48 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HarnessEvent } from "@agent-studio/shared";
import { HarnessClientStore } from "../src/assistant/store";
function event<T extends HarnessEvent["type"]>(sequence: number, type: T, payload: Extract<HarnessEvent, { type: T }>["payload"], runId = "run_1"): HarnessEvent { return { eventId: `evt_${sequence}`, sessionId: "ses_1", runId, timestamp: new Date().toISOString(), sequence, type, payload } as HarnessEvent; }
afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.unstubAllGlobals(); vi.restoreAllMocks(); });
describe("HarnessClientStore reducer", () => {
it("merges streaming deltas into one assistant text part", () => { const store = new HarnessClientStore(); store.reduce(event(1, "assistant.delta", { messageId: "msg_a", delta: "你" })); store.reduce(event(2, "assistant.delta", { messageId: "msg_a", delta: "好" })); expect(store.getSnapshot().messages[0]?.content).toEqual([{ type: "text", text: "你好" }]); });
it("de-duplicates replayed events by id and sequence", () => { const store = new HarnessClientStore(); const value = event(1, "run.started", { modelId: "fake" }); store.reduce(value); store.reduce(value); expect(store.getSnapshot().events).toHaveLength(1); });
it("maps tool and approval state by toolCallId", () => { const store = new HarnessClientStore(); store.reduce(event(1, "tool.requested", { toolCallId: "t1", toolName: "save_report_draft", arguments: { content: "x" } })); store.reduce(event(2, "approval.required", { approvalId: "a1", sessionId: "ses_1", runId: "run_1", toolCallId: "t1", toolName: "save_report_draft", arguments: {}, riskLevel: "medium", createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 1000).toISOString(), status: "pending" })); store.reduce(event(3, "approval.resolved", { approvalId: "a1", toolCallId: "t1", status: "approved", resolvedAt: new Date().toISOString() })); const content = store.getSnapshot().messages[0]?.content; expect(typeof content === "string" ? undefined : content?.[0]).toMatchObject({ type: "tool-call", toolCallId: "t1", approval: { approved: true } }); });
it("upserts duplicate tool requests by toolCallId", () => { const store = new HarnessClientStore(); store.reduce(event(1, "tool.requested", { toolCallId: "t1", toolName: "read", arguments: { path: "<protected-path>" } })); store.reduce(event(2, "tool.requested", { toolCallId: "t1", toolName: "read", arguments: { path: ".agents/skills/ads-analysis/SKILL.md" } })); const content = store.getSnapshot().messages[0]?.content; expect(typeof content === "string" ? [] : content?.filter((part) => part.type === "tool-call")).toHaveLength(1); expect(typeof content === "string" ? undefined : content?.[0]).toMatchObject({ toolCallId: "t1", args: { path: ".agents/skills/ads-analysis/SKILL.md" } }); });
it("maps cancelled and failed runs to incomplete status", () => { const store = new HarnessClientStore(); store.reduce(event(1, "assistant.delta", { messageId: "m", delta: "x" })); store.reduce(event(2, "run.cancelled", { reason: "user" })); expect(store.getSnapshot().messages[0]?.status).toMatchObject({ type: "incomplete", reason: "cancelled" }); });
it("blocks image messages locally for text-only models", async () => { const store = new HarnessClientStore(); const snapshot = store.getSnapshot(); snapshot.sessionId = "ses_1"; snapshot.model = { configured: true, source: "session", providerId: "x", modelId: "x", supportsImages: false, keyConfigured: true }; vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ attachmentId: "att_uploaded", sessionId: "ses_1", name: "x.png", mediaType: "image/png", byteSize: 3, width: 1, height: 1, status: "ready" }) })); vi.stubGlobal("URL", { ...URL, createObjectURL: () => "blob:x", revokeObjectURL: vi.fn() }); const pending = await store.attachmentAdapter.add({ file: new File(["png"], "x.png", { type: "image/png" }) }); if (!("id" in pending)) throw new Error("unexpected generator"); await expect(store.onNew({ id: "ignored", role: "user", parentId: null, sourceId: null, createdAt: new Date(), content: [{ type: "text", text: "x" }], attachments: [{ id: pending.id, type: "image", name: "x", status: { type: "complete" }, content: [{ type: "image", image: "blob:x" }] }], metadata: { custom: {} }, runConfig: undefined })).rejects.toThrow("不支持图片"); expect(fetch).toHaveBeenCalledTimes(1); vi.unstubAllGlobals(); });
it("persists model metadata without the API key", async () => { const store = new HarnessClientStore(); store.getSnapshot().sessionId = "ses_1"; vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ configured: true, source: "session", providerId: "openai", api: "openai-responses", modelId: "gpt", supportsImages: true, keyConfigured: true, keyHint: "••••cret" }) })); await store.saveModel({ providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "top-secret", modelId: "gpt", supportsImages: true }); const raw = localStorage.getItem("agent-studio-mini:model-connection:v1"); expect(raw).not.toContain("top-secret"); expect(JSON.parse(raw!)).toEqual({ providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", modelId: "gpt", supportsImages: true }); });
it("restores saved metadata into a new Session without credentials", async () => { localStorage.setItem("agent-studio-mini:model-connection:v1", JSON.stringify({ providerId: "saved", api: "openai-completions", baseUrl: "https://example.com/v1", modelId: "saved-model", supportsImages: false })); const requests: Array<{ url: string; init?: RequestInit }> = []; vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); requests.push({ url, init }); const body = url === "/api/skills" ? { skills: [] } : url === "/api/sessions" ? { sessionId: "ses_new" } : init?.method === "PUT" ? { configured: true, source: "session", providerId: "saved", api: "openai-completions", baseUrl: "https://example.com/v1", modelId: "saved-model", supportsImages: false, keyConfigured: false } : { configured: false, source: "missing", keyConfigured: false }; return { ok: true, json: async () => body }; })); class FakeEventSource { static readonly CLOSED = 2; readonly readyState = 1; onopen: (() => void) | null = null; onerror: (() => void) | null = null; addEventListener(): void {} close(): void {} } vi.stubGlobal("EventSource", FakeEventSource); const store = new HarnessClientStore(); await store.initialize(); expect(store.getSnapshot().model).toMatchObject({ modelId: "saved-model", keyConfigured: false }); const restored = requests.find((item) => item.init?.method === "PUT"); expect(restored?.init?.body).not.toContain("apiKey"); });
it("drops corrupted persisted configuration without breaking initialization", async () => { localStorage.setItem("agent-studio-mini:model-connection:v1", "not-json"); vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { const url = String(input); const body = url === "/api/skills" ? { skills: [] } : url === "/api/sessions" ? { sessionId: "ses_new" } : { configured: false, source: "missing", keyConfigured: false }; return { ok: true, json: async () => body }; })); class FakeEventSource { static readonly CLOSED = 2; readonly readyState = 1; onopen: (() => void) | null = null; onerror: (() => void) | null = null; addEventListener(): void {} close(): void {} } vi.stubGlobal("EventSource", FakeEventSource); const store = new HarnessClientStore(); await store.initialize(); expect(localStorage.getItem("agent-studio-mini:model-connection:v1")).toBeNull(); expect(store.getSnapshot().model.configured).toBe(false); });
it("waits for an in-flight Session before saving the model", async () => {
let releaseSession!: () => void;
const sessionGate = new Promise<void>((resolve) => { releaseSession = resolve; });
const requests: Array<{ url: string; init?: RequestInit }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input); requests.push({ url, init });
if (url === "/api/sessions" && init?.method === "POST") { await sessionGate; return { ok: true, json: async () => ({ sessionId: "ses_delayed" }) }; }
if (url === "/api/skills") return { ok: true, json: async () => ({ skills: [] }) };
if (init?.method === "PUT") return { ok: true, json: async () => ({ configured: true, source: "session", providerId: "openai", api: "openai-responses", modelId: "gpt", supportsImages: true, keyConfigured: true, keyHint: "••••cret" }) };
return { ok: true, json: async () => ({ configured: false, source: "missing", keyConfigured: false }) };
}));
class FakeEventSource { static readonly CLOSED = 2; readonly readyState = 1; onopen: (() => void) | null = null; onerror: (() => void) | null = null; addEventListener(): void {} close(): void {} }
vi.stubGlobal("EventSource", FakeEventSource);
const store = new HarnessClientStore();
const initializing = store.initialize();
const saving = store.saveModel({ providerId: "openai", api: "openai-responses", apiKey: "top-secret", modelId: "gpt", supportsImages: true });
expect(requests.filter((item) => item.init?.method === "POST")).toHaveLength(1);
expect(requests.some((item) => item.init?.method === "PUT")).toBe(false);
releaseSession();
await Promise.all([initializing, saving]);
expect(requests.find((item) => item.init?.method === "PUT")?.url).toBe("/api/sessions/ses_delayed/model-config");
expect(store.getSnapshot().model).toMatchObject({ configured: true, keyConfigured: true });
});
it("reports the Session initialization failure instead of a false save success", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
const store = new HarnessClientStore();
await expect(store.saveModel({ providerId: "openai", api: "openai-responses", apiKey: "top-secret", modelId: "gpt", supportsImages: true })).rejects.toThrow("Session 初始化失败:Failed to fetch");
});
});
+42
View File
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HarnessEvent } from "@agent-studio/shared";
import { App } from "../src/App";
import { HarnessRuntime } from "../src/assistant/HarnessRuntime";
import { HarnessClientStore } from "../src/assistant/store";
import { MockAdsMetricsUI, SaveReportDraftUI } from "../src/components/ToolUIs";
import { AppErrorBoundary } from "../src/components/AppErrorBoundary";
afterEach(() => { localStorage.clear(); sessionStorage.clear(); vi.restoreAllMocks(); });
describe("web UI", () => {
it("renders the Skills, Chat, and Timeline operating areas", () => { const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); expect(screen.getByLabelText("Skills Catalog")).toBeInTheDocument(); expect(screen.getByLabelText("Streaming Chat")).toBeInTheDocument(); expect(screen.getByLabelText("Event Timeline")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /尚未配置模型/ })).toBeInTheDocument(); });
it("renders the mock metrics Tool UI", () => { render(<MockAdsMetricsUI {...toolProps("mock_ads_metrics", { accountId: "demo-account", days: 7 }, { details: { spend: 100, ctr: 2, cpa: 4, roas: 5 } })}/>); expect(screen.getByText("demo-account", { exact: false })).toBeInTheDocument(); expect(screen.getByText("ROAS")).toBeInTheDocument(); });
it("renders approval actions inside save_report_draft", () => { const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><SaveReportDraftUI {...toolProps("save_report_draft", { content: "报告正文" }, undefined, { id: "apr_1" })}/></HarnessRuntime>); expect(screen.getByRole("button", { name: "批准一次" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "拒绝" })).toBeInTheDocument(); });
it("opens settings with password input and does not use Web Storage", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: /尚未配置模型/ })); expect(screen.getByLabelText("API Key")).toHaveAttribute("type", "password"); expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); });
it("prompts for a new key when local model metadata was restored", () => { const store = new HarnessClientStore(); store.getSnapshot().model = { configured: true, source: "session", providerId: "saved", api: "openai-completions", modelId: "saved-model", supportsImages: false, keyConfigured: false }; render(<HarnessRuntime store={store}><App/></HarnessRuntime>); expect(screen.getByRole("button", { name: /模型配置已恢复/ })).toBeInTheDocument(); expect(screen.getByLabelText("消息输入")).toHaveAttribute("placeholder", "请重新输入 API Key"); });
it("omits empty optional model fields when saving settings", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); const save = vi.spyOn(store, "saveModel").mockResolvedValue(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: /尚未配置模型/ })); await user.type(screen.getByLabelText("API Key"), "sk-fake"); await user.click(screen.getByRole("button", { name: "保存配置" })); expect(save).toHaveBeenCalledWith({ providerId: "openai", api: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "sk-fake", modelId: "gpt-5.4", supportsImages: true }); });
it("fills the classroom demo prompt without sending it", async () => { const user = userEvent.setup(); const store = new HarnessClientStore(); render(<HarnessRuntime store={store}><App/></HarnessRuntime>); await user.click(screen.getByRole("button", { name: "填入 Demo Prompt" })); expect((screen.getByLabelText("消息输入") as HTMLTextAreaElement).value).toContain("ads-analysis"); expect(store.getSnapshot().events).toHaveLength(0); });
it("shows a recoverable fallback instead of a black screen for render failures", () => { vi.spyOn(console, "error").mockImplementation(() => undefined); const Broken = () => { throw new Error("tool renderer failed"); }; render(<AppErrorBoundary><Broken/></AppErrorBoundary>); expect(screen.getByRole("alert")).toHaveTextContent("界面渲染出现异常"); expect(screen.getByRole("button", { name: "重新加载页面" })).toBeInTheDocument(); });
it("renders a duplicated Pi Tool sequence and approval without crashing", () => {
const store = new HarnessClientStore();
store.reduce(harnessEvent(1, "user.message", { messageId: "user_1", text: "demo", attachmentIds: [] }));
store.reduce(harnessEvent(2, "run.started", { modelId: "fake" }));
store.reduce(harnessEvent(3, "assistant.completed", { messageId: "assistant_1" }));
store.reduce(harnessEvent(4, "tool.requested", { toolCallId: "read_1", toolName: "read", arguments: { path: "<protected-path>" } }));
store.reduce(harnessEvent(5, "tool.requested", { toolCallId: "read_1", toolName: "read", arguments: { path: ".agents/skills/ads-analysis/SKILL.md" } }));
store.reduce(harnessEvent(6, "tool.completed", { toolCallId: "read_1", toolName: "read", result: { status: "success" } }));
store.reduce(harnessEvent(7, "tool.requested", { toolCallId: "metrics_1", toolName: "mock_ads_metrics", arguments: { accountId: "demo-account", days: 7 } }));
store.reduce(harnessEvent(8, "tool.completed", { toolCallId: "metrics_1", toolName: "mock_ads_metrics", result: { details: { spend: 100, ctr: 2, cpa: 4, roas: 5 } } }));
store.reduce(harnessEvent(9, "tool.requested", { toolCallId: "save_1", toolName: "save_report_draft", arguments: { content: "报告正文" } }));
store.reduce(harnessEvent(10, "approval.required", { approvalId: "apr_1", sessionId: "ses_1", runId: "run_1", toolCallId: "save_1", toolName: "save_report_draft", arguments: { content: "报告正文" }, riskLevel: "medium", createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), status: "pending" }));
render(<AppErrorBoundary><HarnessRuntime store={store}><App/></HarnessRuntime></AppErrorBoundary>);
expect(screen.queryByText("界面渲染出现异常")).not.toBeInTheDocument();
expect(screen.getByText("ROAS")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "允许保存报告草稿?" })).toBeInTheDocument();
});
});
function toolProps(toolName: string, args: Record<string, unknown>, result?: unknown, approval?: { id: string }) { return { type: "tool-call" as const, toolCallId: "t1", toolName, args, argsText: JSON.stringify(args), result, isError: false, status: { type: "complete" as const }, addResult: vi.fn(), resume: vi.fn(), respondToApproval: vi.fn(), ...(approval ? { approval } : {}) }; }
function harnessEvent<T extends HarnessEvent["type"]>(sequence: number, type: T, payload: Extract<HarnessEvent, { type: T }>["payload"]): HarnessEvent { return { eventId: `evt_${sequence}`, sessionId: "ses_1", runId: "run_1", timestamp: new Date().toISOString(), sequence, type, payload } as HarnessEvent; }
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "noEmit": true, "types": ["vite/client"] },
"include": ["src", "vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { port: 5173, proxy: { "/api": "http://127.0.0.1:3001", "/health": "http://127.0.0.1:3001" } },
build: { outDir: "dist" },
});
+55
View File
@@ -0,0 +1,55 @@
# Architecture
## Component map
```mermaid
flowchart TB
subgraph Browser
C[assistant-ui Composer]
T[Thread + Tool UI]
E[Event Timeline]
S[HarnessClientStore]
C --> S
S --> T
S --> E
end
subgraph Server
F[Fastify protocol layer]
R[SessionRegistry]
ES[EventStore]
AB[ApprovalBroker]
AS[AttachmentStore]
MS[ModelConnectionStore]
P[Pi AgentSession]
L[DefaultResourceLoader]
W[SafeWorkspace]
end
S -->|JSON / multipart| F
F -->|SSE| S
F --> R
F --> AS
F --> MS
R --> P
R --> ES
P --> L
P --> AB
P --> W
```
`apps/server` 只做请求校验、错误映射、SSE framing 与对象组合。`packages/harness` 拥有 Agent 生命周期和所有核心状态接口。`packages/shared` 不依赖 Node,定义前后端唯一协议。`apps/web` 不拥有另一套 Agent 状态机,只用 sequence reducer 投影 EventStore。
## Session creation and run
1. Web 创建 Harness Session;服务器建立独立 cwd、事件序列、附件和凭证命名空间。
2. 服务启动时严格加载并缓存 `.env` 默认模型;新 Session 优先使用该配置。没有环境配置时,Web 可把 localStorage 中通过 strict Zod 校验的非敏感模型元数据恢复到该 Session。API Key 不参与浏览器持久化,来自环境变量或服务器 AES-256-GCM 凭证库。
3. 首次 Run 使用精确模型创建独立 Pi `AgentSession``SessionManager.inMemory(cwd)` 与安全 extension。
4. message 立即得到 202;后台调用 `prompt`Pi 事件由单一 adapter 映射并写 EventStore。
5. SSE 将历史和实时事件交给 `HarnessClientStore`assistant-ui External Store Runtime 只负责呈现。
## Storage replacement seams
`EventStore``ApprovalBroker``AttachmentStore``ModelConnectionStore``SessionRegistry` 都有接口。内存/本地临时文件实现可分别替换为 Redis streams、Postgres、对象存储、KMS 与分布式 worker,而不改变 Web 协议。
## Failure and shutdown
Run timeout 和用户 cancel 使用不同 reasonApproval wait 监听同一 AbortSignal。删除先 abort,再 cancel pending approval,关闭订阅与 SSE,调用 Pi `dispose()`,清凭证/附件,最后在 canonical sessions root 校验后删除 cwd。单个 SSE 断开不取消 Run。
+37
View File
@@ -0,0 +1,37 @@
# 1015 minute classroom demo
## 0:002:00 — boundaries
启动 `pnpm dev`,展示三栏:左侧只显示 Skill catalog,不显示正文;中间由 assistant-ui 渲染;右侧是 EventStore 的审计时间线。强调 Pi 是 Agent Loop 事实来源,assistant-ui 不直接访问模型。
## 2:004:00 — model connection
打开 Settings,比较 OpenAI、Anthropic、OpenAI-compatible preset。说明模型元数据会保存在 localStorage,但 key 不回填、不进 Web Storage;刷新后字段自动恢复,必须重输 Key,Composer 才会启用。课堂无需真实调用时可只演示表单和测试返回的“首次运行验证”。
## 4:008:00 — main path
点击 Demo Prompt(它只填入,不自动执行):
> 请使用 ads-analysis Skill,分析账户 demo-account 最近 7 天数据,找出异常 Campaign,并输出账户总结、关键指标和 3 条优化建议。最后使用 report-writer Skill 生成一份报告草稿。
有可用课堂模型时发送,观察:`run.started``skill.requested``skill.loaded``mock_ads_metrics` Tool 卡片 → streaming delta。指出指标来自 fixture,不是真实广告平台。
## 8:0011:00 — approval
`save_report_draft` 出现时,展示上下文 Tool 卡片与 modal。先讲清 preflight 尚未写文件。
- Approve 路径:点击“批准一次”,等待 SSE 权威状态,再查看当前 Session 的 `report.md`
- Deny 路径:重开一个 Session,再拒绝;确认没有文件但 Agent 可在聊天给出正文。
- Timeout 可将 `APPROVAL_TIMEOUT_MS` 临时缩短后演示;最终状态以服务器事件为准,不以浏览器倒计时为准。
## 11:0013:00 — image path
选择/拖拽/粘贴一张 JPEG、PNG 或 WebP,打开缩略图 Dialog,再附加:
> 请同时观察我上传的图片,将可见内容作为辅助信号;不要根据图片编造图片中不存在的数据,数值指标以 mock_ads_metrics 返回的数据为准。
说明 browser object URL、流式 upload、Sharp 重编码、attachmentId、服务端 ImageContent 的链路。切换到 text-only 配置,演示发送前阻止图片。
## 13:0015:00 — failure and production boundary
停止一个 Run,查看 `run.cancelled`。断开/刷新页面说明 SSE 重放与去重。最后强调:Pi Project Trust 不是 Sandbox;不可信任务需要容器/VM/micro-VM,课堂内存 Store 也不是生产持久层。
+25
View File
@@ -0,0 +1,25 @@
# Event mapping
Pi 原生事件到 HarnessEvent 的转换集中在 `packages/harness/src/pi-event-adapter.ts`。未识别事件安全忽略,不会让 Run 崩溃。
| Pi / Harness fact | HarnessEvent | assistant-ui / UI state |
| --- | --- | --- |
| message text delta | `assistant.delta` | 按 messageId 合并文本,status running |
| assistant message end | `assistant.completed` | message status completepolite live announcement |
| extension `tool_call` preflight | `tool.requested` | Tool part running / approval pending |
| tool execution success | `tool.completed` | Tool result success |
| tool execution error | `tool.failed` | Tool result error |
| canonical Skill `read` request | `skill.requested` | Catalog 高亮 requested |
| 同一 read 成功结果 | `skill.loaded` | Catalog 高亮 loaded |
| ApprovalBroker request | `approval.required` | Tool card + modal pending |
| broker decision/timeout/cancel | `approval.resolved` | approved/denied/expired/cancelled |
| registry accepts message | `user.message`, `run.started` | user message + running |
| prompt resolves | `run.completed` | run idle |
| abort by user/system | `run.cancelled` | incomplete,显示 reason |
| prompt/create failure | `run.failed` | incomplete/error |
Skill requested/loaded 是 Harness 推导事件,不是 Pi 原生事件。匹配依据是 `DefaultResourceLoader` 已发现的 canonical `SKILL.md` 索引;仅文件名相同不算命中。
`tool.requested` 在 extension preflight 写入,后续 Pi `tool_execution_start` 按 toolCallId 去重。读取类 Tool 的 completed event 只暴露安全摘要,避免把 Skill 正文或文件内容复制到 Timeline。参数会限长、敏感字段脱敏,绝对路径转换为项目相对路径或 `<protected-path>`
SSE 事件 id 是 eventId,事件名是 HarnessEvent type。`Last-Event-ID` 比 query `afterSequence` 优先;若 cursor 早于 1000 条保留窗口,先发送 `stream.gap`,客户端重新建立可识别边界,而不是静默丢事件。
+33
View File
@@ -0,0 +1,33 @@
# Production roadmap
## Persistence and distribution
1. EventStore 迁移到 Redis Streams 或 Kafka,并把长期审计写 Postgres/不可变对象存储。
2. Session/Approval 元数据进入 Postgres,运行锁、timeout、SSE fan-out 使用 Redis。
3. AttachmentStore 使用带生命周期策略的对象存储、短期签名 URL、恶意内容扫描和租户配额。
4. 把 Agent Run 放入 durable queue/worker,支持幂等恢复、worker lease 与跨实例取消。
## Secrets and identity
1. KMS/Vault envelope encryption,短期 scoped credential,不在长期 Store 保存明文。
2. OIDC/SSO、租户/RBAC/ABAC、每个 Session 所有权校验。
3. CSRF 防护、严格 same-site cookie、CSP、Origin 检查、rate limit、配额与 abuse detection。
4. 管理员批准策略、双人审批和不可抵赖审计。
## Isolation
1. 每 Run 使用无特权容器、VM 或 micro-VM;只读输入、最小可写输出。
2. seccomp/AppArmor、PID/CPU/内存/磁盘/time limits、无 host socket。
3. egress allowlist proxyDNS pinning,重定向逐跳校验,metadata 永久 deny。
4. Skill 签名、来源策略、版本锁与离线扫描。
## Reliability and observability
1. 分布式 SSE/WebSocket gatewaycursor 持久化、backpressure 和 gap recovery。
2. OpenTelemetry traceHTTP → run → model → tool → approval;指标不含 prompt/key/image body。
3. Provider circuit breaker、预算、token/cost accounting、重试分类和 dead-letter queue。
4. chaos tests、断电恢复、跨版本 event schema migration 和数据保留策略。
## Frontend scale
代码分割 assistant-ui/设置/附件预览,虚拟化长 Timeline 与 Thread;增加离线恢复、上传续传和生产级国际化。保持 HarnessClientStore 是唯一投影状态,不在 Runtime 中复制 Agent 状态机。
+41
View File
@@ -0,0 +1,41 @@
# Security boundaries
## Trust model
Pi 没有内置 Sandbox。Project Trust 只控制项目资源加载,不隔离进程、文件系统或网络。Pi、Extension 与 Tool 都继承 Node 进程权限。本项目是安全教学基线,不是恶意代码隔离器。
## Credentials and logs
- API Key 只经同源 body 进入服务端;Pi 运行时仍使用 Session 专属 `InMemoryCredentialStore`
- 前端提交的 API Key 使用 `MODEL_CREDENTIAL_ENCRYPTION_KEY` 提供的 32-byte master key 做 AES-256-GCM 加密,采用随机 96-bit IV、认证标签和模型连接 identity AAD;密文文件使用 `0600` 权限与临时文件原子 rename。master key 只来自 `.env`/进程环境,绝不写入密文文件。
- 浏览器刷新创建新 Session 时,只提交非敏感模型 identity;服务端按 identity 解密恢复对应凭证。删除 Session 不删除持久凭证,只有 Clear Credentials 会删除。
- 浏览器 localStorage 只保存白名单模型元数据;保存前主动移除 `apiKey` 并用不接受额外字段的 Zod schema 校验。损坏或超限数据会删除。
- 不写 Pi 全局 auth/models 文件,不执行 `!command`,不接受自定义 headers。
- 响应仅回传配置状态和末四位 hint;SSE、事件、日志和测试快照禁止原始 key。
- Fastify 只记录安全元数据并对 Authorization/API Key 路径 redaction;生产错误不返回 stack/绝对路径。
## Filesystem
- 每个 Session 独立 canonical cwdresolve 后必须仍在 root 内。
- `realpath` 验证父目录,拒绝 `..`、绝对路径逃逸与符号链接路径。
- `.env``.git`、SSH、用户主目录和系统路径受到 preflight 保护。
- `save_report_draft` 目标固定为 `report.md`,临时排他创建后原子 rename;审批不能替代路径检查。
- 默认不注册 shell、write、edit。
课堂实现使用 Node 文件 API,无法完全消除恶意同机进程造成的 TOCTOU。生产版应把每个任务放进独立 mount namespace / micro-VM,并使用只读输入卷和受控输出卷。
## Images
multipart 流有字节上限;扩展名、MIME、magic bytes 三重匹配。Sharp 限制像素、解码并重新编码,自动旋转且移除多余 metadata。随机文件名仅写 Session attachments,预览不提供任意路径静态服务。事件仅记录 ID、MIME、大小和尺寸。
## Model Base URL and SSRF
默认 HTTPS;拒绝 URL credentials、fragment、危险 credential query、畸形 URL、metadata hostname,以及 DNS 解析得到的 private/loopback/link-local 地址。开发环境可使用 loopback HTTP,企业内网必须显式 opt-in。DNS rebinding、重定向逐跳策略和出口 DNS 固定应由生产 egress proxy 强制执行。
## Approval
审批发生在 Pi `tool_call` preflightTool 尚未执行。Broker 校验当前 Session/Run、响应 abort、释放 timer/Promise,并将 approve/deny/expired/cancelled 作为服务器事实发送。未知工具 deny,未注册高风险工具无法被模型调用。
## Required production isolation
不可信 Skill、仓库、脚本、附件解析或无人值守 Agent 必须在 Docker(配合 seccomp/AppArmor 和无特权用户)、VM、micro-VM 或远程 Sandbox 运行,并施加 CPU/内存/磁盘/时间/网络配额。Project Trust 不能替代这些措施。
+21
View File
@@ -0,0 +1,21 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
export default tseslint.config(
{ ignores: ["**/dist/**", "**/coverage/**", "workspace/**", "node_modules/**"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: { globals: { ...globals.node, ...globals.browser } },
rules: { "@typescript-eslint/no-explicit-any": "error" },
},
{
files: ["apps/web/**/*.{ts,tsx}"],
plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh },
rules: { ...reactHooks.configs.recommended.rules, "react-refresh/only-export-components": ["warn", { allowConstantExport: true }] },
},
);
+10
View File
@@ -0,0 +1,10 @@
{
"accountId": "demo-account",
"currency": "USD",
"timeRange": { "from": "2026-08-21", "to": "2026-08-27" },
"campaigns": [
{ "id": "cmp_brand", "name": "Brand Search", "spend": 840.25, "impressions": 121400, "clicks": 5630, "conversions": 392, "revenue": 8232.00 },
{ "id": "cmp_prospect", "name": "Prospecting Video", "spend": 2310.50, "impressions": 540200, "clicks": 7280, "conversions": 118, "revenue": 3186.00 },
{ "id": "cmp_retarget", "name": "Retargeting", "spend": 1264.75, "impressions": 98200, "clicks": 4515, "conversions": 244, "revenue": 6832.00 }
]
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "agent-studio-mini",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@11.24.0",
"engines": { "node": ">=22.19.0" },
"scripts": {
"dev": "pnpm --parallel --filter @agent-studio/server --filter @agent-studio/web dev",
"lint": "eslint . --max-warnings 0",
"typecheck": "pnpm -r typecheck",
"test": "vitest run",
"build": "pnpm -r build"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.2.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6",
"vitest": "^4.0.15"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@agent-studio/harness",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { ".": { "types": "./src/index.ts", "development": "./src/index.ts", "import": "./dist/index.js", "default": "./dist/index.js" } },
"scripts": { "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.json" },
"dependencies": {
"@agent-studio/shared": "workspace:*",
"@earendil-works/pi-ai": "0.84.3",
"@earendil-works/pi-coding-agent": "0.84.3",
"sharp": "^0.34.5",
"typebox": "1.3.7",
"zod": "^4.1.13"
},
"devDependencies": { "@types/node": "^24.10.1", "typescript": "^5.9.3" }
}
+74
View File
@@ -0,0 +1,74 @@
import { randomUUID } from "node:crypto";
import type { ApprovalRequest } from "@agent-studio/shared";
import { HarnessError, redactValue } from "./errors.js";
export type ApprovalStatus = "approved" | "denied" | "expired" | "cancelled";
export interface ApprovalDecision { status: ApprovalStatus; resolvedAt: string }
export interface ApprovalRequestInput { sessionId: string; runId: string; toolCallId: string; toolName: string; arguments: unknown; riskLevel: "low" | "medium" | "high" }
export interface ApprovalResolution { request: ApprovalRequest; decision: ApprovalDecision }
export interface ApprovalBroker {
request(input: ApprovalRequestInput, signal: AbortSignal): Promise<ApprovalDecision>;
approve(approvalId: string): ApprovalResolution;
deny(approvalId: string): ApprovalResolution;
cancelForSession(sessionId: string): void;
get(approvalId: string): ApprovalRequest | undefined;
listPending(sessionId: string): ApprovalRequest[];
}
interface Pending { request: ApprovalRequest; resolve: (value: ApprovalDecision) => void; timer: ReturnType<typeof setTimeout>; signal: AbortSignal; abort: () => void; decision?: ApprovalDecision }
export class InMemoryApprovalBroker implements ApprovalBroker {
private readonly approvals = new Map<string, Pending>();
constructor(
private readonly timeoutMs = 60_000,
private readonly onResolution?: (value: ApprovalResolution) => void,
private readonly onRequest?: (value: ApprovalRequest) => void,
) {}
request(input: ApprovalRequestInput, signal: AbortSignal): Promise<ApprovalDecision> {
const now = Date.now();
const approvalId = `apr_${randomUUID()}`;
const request: ApprovalRequest = {
...input,
approvalId,
arguments: redactValue(input.arguments),
createdAt: new Date(now).toISOString(),
expiresAt: new Date(now + this.timeoutMs).toISOString(),
status: "pending",
};
return new Promise((resolve) => {
const entry: Pending = { request, resolve, timer: setTimeout(() => this.finish(approvalId, "expired"), this.timeoutMs), signal, abort: () => this.finish(approvalId, "cancelled") };
this.approvals.set(approvalId, entry);
this.onRequest?.(request);
if (signal.aborted) entry.abort();
else signal.addEventListener("abort", entry.abort, { once: true });
});
}
private finish(approvalId: string, status: ApprovalStatus): ApprovalResolution {
const entry = this.approvals.get(approvalId);
if (!entry) throw new HarnessError("APPROVAL_NOT_FOUND", "Approval not found", 404);
if (entry.decision) {
if (entry.decision.status !== status) throw new HarnessError("APPROVAL_ALREADY_RESOLVED", "Approval already resolved", 409);
return { request: entry.request, decision: entry.decision };
}
clearTimeout(entry.timer);
entry.signal.removeEventListener("abort", entry.abort);
const decision = { status, resolvedAt: new Date().toISOString() };
entry.decision = decision;
entry.request = { ...entry.request, status };
entry.resolve(decision);
const resolution = { request: entry.request, decision };
this.onResolution?.(resolution);
return resolution;
}
approve(id: string): ApprovalResolution { return this.finish(id, "approved"); }
deny(id: string): ApprovalResolution { return this.finish(id, "denied"); }
cancelForSession(sessionId: string): void {
for (const [id, entry] of this.approvals) if (entry.request.sessionId === sessionId && !entry.decision) this.finish(id, "cancelled");
}
get(id: string): ApprovalRequest | undefined { return this.approvals.get(id)?.request; }
listPending(sessionId: string): ApprovalRequest[] { return [...this.approvals.values()].filter((item) => item.request.sessionId === sessionId && !item.decision).map((item) => item.request); }
}
+68
View File
@@ -0,0 +1,68 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import type { ImageAttachment } from "@agent-studio/shared";
import sharp from "sharp";
import { HarnessError } from "./errors.js";
export interface UploadStream { filename: string; mimetype: string; stream: AsyncIterable<Uint8Array> }
export interface StoredAttachment extends ImageAttachment { filePath: string }
export interface AttachmentStore {
add(sessionId: string, upload: UploadStream): Promise<ImageAttachment>;
get(sessionId: string, attachmentId: string): ImageAttachment | undefined;
getStored(sessionId: string, attachmentId: string): StoredAttachment | undefined;
remove(sessionId: string, attachmentId: string): Promise<void>;
removeAll(sessionId: string): Promise<void>;
}
const mediaExtensions = new Map([["image/jpeg", new Set([".jpg", ".jpeg"])], ["image/png", new Set([".png"])], ["image/webp", new Set([".webp"])]]);
function magicMedia(buffer: Buffer): "image/jpeg" | "image/png" | "image/webp" | undefined {
if (buffer.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return "image/jpeg";
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
return undefined;
}
export class FileAttachmentStore implements AttachmentStore {
private readonly items = new Map<string, StoredAttachment>();
constructor(private readonly sessionsRoot: string, private readonly maxBytes = 5 * 1024 * 1024, private readonly maxPixels = 40_000_000) {}
private key(sessionId: string, attachmentId: string): string { return `${sessionId}:${attachmentId}`; }
async add(sessionId: string, upload: UploadStream): Promise<ImageAttachment> {
const declared = upload.mimetype.toLowerCase();
if (!mediaExtensions.has(declared)) throw new HarnessError("ATTACHMENT_TYPE_UNSUPPORTED", "Only JPEG, PNG, and WebP images are accepted", 415);
const extension = path.extname(upload.filename).toLowerCase();
if (!mediaExtensions.get(declared)?.has(extension)) throw new HarnessError("ATTACHMENT_EXTENSION_MISMATCH", "File extension does not match its MIME type", 415);
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of upload.stream) {
size += chunk.byteLength;
if (size > this.maxBytes) throw new HarnessError("ATTACHMENT_TOO_LARGE", "Image exceeds the configured size limit", 413);
chunks.push(Buffer.from(chunk));
}
const source = Buffer.concat(chunks);
const detected = magicMedia(source);
if (!detected || detected !== declared) throw new HarnessError("ATTACHMENT_MAGIC_MISMATCH", "Image content does not match its declared type", 415);
const metadata = await sharp(source, { limitInputPixels: this.maxPixels, failOn: "error" }).metadata().catch(() => { throw new HarnessError("ATTACHMENT_INVALID_IMAGE", "Image cannot be decoded safely", 422); });
if (!metadata.width || !metadata.height || metadata.width * metadata.height > this.maxPixels) throw new HarnessError("ATTACHMENT_DIMENSIONS_EXCEEDED", "Image dimensions exceed the configured limit", 413);
const pipeline = sharp(source, { limitInputPixels: this.maxPixels, failOn: "error" }).rotate();
const normalized = detected === "image/png" ? await pipeline.png({ compressionLevel: 9 }).toBuffer() : detected === "image/webp" ? await pipeline.webp({ quality: 88 }).toBuffer() : await pipeline.jpeg({ quality: 90, mozjpeg: true }).toBuffer();
const outputMetadata = await sharp(normalized).metadata();
const attachmentId = `att_${randomUUID()}`;
const directory = path.join(this.sessionsRoot, sessionId, "attachments");
await mkdir(directory, { recursive: true, mode: 0o700 });
const finalPath = path.join(directory, `${attachmentId}${extension}`);
const temporary = `${finalPath}.tmp`;
await writeFile(temporary, normalized, { mode: 0o600, flag: "wx" });
await rename(temporary, finalPath);
const item: StoredAttachment = { attachmentId, sessionId, name: path.basename(upload.filename).slice(0, 200), mediaType: detected, byteSize: normalized.byteLength, width: outputMetadata.width ?? metadata.width, height: outputMetadata.height ?? metadata.height, status: "ready", filePath: finalPath };
this.items.set(this.key(sessionId, attachmentId), item);
return this.public(item);
}
get(sessionId: string, id: string): ImageAttachment | undefined { const item = this.items.get(this.key(sessionId, id)); return item ? this.public(item) : undefined; }
getStored(sessionId: string, id: string): StoredAttachment | undefined { return this.items.get(this.key(sessionId, id)); }
async readBase64(sessionId: string, id: string): Promise<string> { const item = this.getStored(sessionId, id); if (!item) throw new HarnessError("ATTACHMENT_NOT_FOUND", "Attachment not found", 404); return (await readFile(item.filePath)).toString("base64"); }
async remove(sessionId: string, id: string): Promise<void> { const key = this.key(sessionId, id); const item = this.items.get(key); if (!item) return; await unlink(item.filePath).catch(() => undefined); this.items.delete(key); }
async removeAll(sessionId: string): Promise<void> { for (const [key] of this.items) if (key.startsWith(`${sessionId}:`)) this.items.delete(key); await rm(path.join(this.sessionsRoot, sessionId, "attachments"), { recursive: true, force: true }); }
private public(item: StoredAttachment): ImageAttachment { return { attachmentId: item.attachmentId, sessionId: item.sessionId, name: item.name, mediaType: item.mediaType, byteSize: item.byteSize, width: item.width, height: item.height, status: item.status }; }
}
+127
View File
@@ -0,0 +1,127 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import type { ModelConnectionInput } from "@agent-studio/shared";
import { HarnessError } from "./errors.js";
const encryptedValueSchema = z.object({
iv: z.string().min(1),
tag: z.string().min(1),
ciphertext: z.string().min(1),
}).strict();
const vaultDocumentSchema = z.object({
version: z.literal(1),
entries: z.record(z.string(), encryptedValueSchema),
}).strict();
type VaultDocument = z.infer<typeof vaultDocumentSchema>;
export interface CredentialVault {
get(identity: string): Promise<string | undefined>;
set(identity: string, apiKey: string): Promise<void>;
delete(identity: string): Promise<void>;
}
export function modelCredentialIdentity(input: ModelConnectionInput): string {
const stable = JSON.stringify({
providerId: input.providerId,
api: input.api,
baseUrl: input.baseUrl ?? null,
modelId: input.modelId,
});
return createHash("sha256").update(stable).digest("base64url");
}
export class EncryptedFileCredentialVault implements CredentialVault {
private readonly key: Buffer;
private document: VaultDocument = { version: 1, entries: {} };
private loaded: Promise<void> | undefined;
private mutation: Promise<void> = Promise.resolve();
constructor(private readonly filePath: string, encodedMasterKey: string) {
this.key = Buffer.from(encodedMasterKey.trim(), "base64");
if (this.key.byteLength !== 32) {
throw new HarnessError("INVALID_CREDENTIAL_ENCRYPTION_KEY", "MODEL_CREDENTIAL_ENCRYPTION_KEY must be a base64-encoded 32-byte key", 500);
}
}
async get(identity: string): Promise<string | undefined> {
await this.mutation;
await this.ensureLoaded();
const value = this.document.entries[identity];
if (!value) return undefined;
try {
const decipher = createDecipheriv("aes-256-gcm", this.key, Buffer.from(value.iv, "base64"));
decipher.setAAD(this.aad(identity));
decipher.setAuthTag(Buffer.from(value.tag, "base64"));
return Buffer.concat([decipher.update(Buffer.from(value.ciphertext, "base64")), decipher.final()]).toString("utf8");
} catch {
throw new HarnessError("CREDENTIAL_DECRYPTION_FAILED", "Stored model credential could not be decrypted with the configured master key", 500);
}
}
set(identity: string, apiKey: string): Promise<void> {
return this.enqueue(async () => {
await this.ensureLoaded();
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
cipher.setAAD(this.aad(identity));
const ciphertext = Buffer.concat([cipher.update(apiKey, "utf8"), cipher.final()]);
this.document.entries[identity] = {
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
ciphertext: ciphertext.toString("base64"),
};
await this.persist();
});
}
delete(identity: string): Promise<void> {
return this.enqueue(async () => {
await this.ensureLoaded();
if (!(identity in this.document.entries)) return;
delete this.document.entries[identity];
await this.persist();
});
}
private ensureLoaded(): Promise<void> {
if (!this.loaded) this.loaded = this.load();
return this.loaded;
}
private async load(): Promise<void> {
try {
const raw = await readFile(this.filePath, "utf8");
this.document = vaultDocumentSchema.parse(JSON.parse(raw));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
if (error instanceof HarnessError) throw error;
throw new HarnessError("CREDENTIAL_STORE_CORRUPT", "Encrypted model credential store is invalid", 500);
}
}
private enqueue(operation: () => Promise<void>): Promise<void> {
const next = this.mutation.then(operation, operation);
this.mutation = next.catch(() => undefined);
return next;
}
private async persist(): Promise<void> {
const directory = path.dirname(this.filePath);
await mkdir(directory, { recursive: true, mode: 0o700 });
const temporary = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
try {
await writeFile(temporary, `${JSON.stringify(this.document)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
await rename(temporary, this.filePath);
} finally {
await rm(temporary, { force: true }).catch(() => undefined);
}
}
private aad(identity: string): Buffer {
return Buffer.from(`agent-studio-mini:model-credential:v1:${identity}`, "utf8");
}
}
+31
View File
@@ -0,0 +1,31 @@
export class HarnessError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly statusCode = 400,
public readonly details: Record<string, unknown> = {},
) {
super(message);
this.name = "HarnessError";
}
}
export function safeErrorMessage(error: unknown): string {
if (!(error instanceof Error)) return "Unexpected error";
return error.message
.replace(/(?:sk|pk|key)-[A-Za-z0-9_-]{8,}/g, "[REDACTED]")
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
.replace(/[A-Za-z0-9+/]{200,}={0,2}/g, "[REDACTED_DATA]");
}
const sensitive = /api[-_]?key|authorization|token|secret|password|credential/i;
export function redactValue(value: unknown, depth = 0): unknown {
if (depth > 6) return "[TRUNCATED]";
if (typeof value === "string") return value.length > 2_000 ? `${value.slice(0, 2_000)}` : safeErrorMessage(new Error(value));
if (Array.isArray(value)) return value.slice(0, 50).map((item) => redactValue(item, depth + 1));
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).slice(0, 50).map(([key, item]) => [key, sensitive.test(key) ? "[REDACTED]" : redactValue(item, depth + 1)]));
}
return value;
}
+65
View File
@@ -0,0 +1,65 @@
import { randomUUID } from "node:crypto";
import type { EventInput, HarnessEvent, HarnessEventType } from "@agent-studio/shared";
export interface EventCursor { sequence?: number; eventId?: string }
export interface EventBatch { events: HarnessEvent[]; gap?: { requestedSequence: number; oldestAvailableSequence: number } }
export interface EventStore {
append<T extends HarnessEventType>(sessionId: string, event: EventInput<T>): Extract<HarnessEvent, { type: T }>;
listAfter(sessionId: string, cursor?: EventCursor): EventBatch;
subscribe(sessionId: string, listener: (event: HarnessEvent) => void): () => void;
clear(sessionId: string): void;
}
interface Bucket { sequence: number; events: HarnessEvent[]; listeners: Set<(event: HarnessEvent) => void> }
export class InMemoryEventStore implements EventStore {
private readonly buckets = new Map<string, Bucket>();
constructor(private readonly limit = 1_000) {}
private bucket(sessionId: string): Bucket {
let bucket = this.buckets.get(sessionId);
if (!bucket) {
bucket = { sequence: 0, events: [], listeners: new Set() };
this.buckets.set(sessionId, bucket);
}
return bucket;
}
append<T extends HarnessEventType>(sessionId: string, input: EventInput<T>): Extract<HarnessEvent, { type: T }> {
const bucket = this.bucket(sessionId);
const value = {
...input,
eventId: `evt_${randomUUID()}`,
sessionId,
timestamp: new Date().toISOString(),
sequence: ++bucket.sequence,
} as Extract<HarnessEvent, { type: T }>;
bucket.events.push(value);
if (bucket.events.length > this.limit) bucket.events.splice(0, bucket.events.length - this.limit);
for (const listener of bucket.listeners) listener(value);
return value;
}
listAfter(sessionId: string, cursor: EventCursor = {}): EventBatch {
const bucket = this.bucket(sessionId);
let requested = cursor.sequence ?? 0;
if (cursor.eventId) {
const found = bucket.events.find((item) => item.eventId === cursor.eventId);
if (found) requested = found.sequence;
}
const oldest = bucket.events[0]?.sequence;
if (oldest !== undefined && requested > 0 && requested < oldest - 1) {
return { events: bucket.events.slice(), gap: { requestedSequence: requested, oldestAvailableSequence: oldest } };
}
return { events: bucket.events.filter((item) => item.sequence > requested) };
}
subscribe(sessionId: string, listener: (event: HarnessEvent) => void): () => void {
const listeners = this.bucket(sessionId).listeners;
listeners.add(listener);
return () => listeners.delete(listener);
}
clear(sessionId: string): void { this.buckets.delete(sessionId); }
}
+11
View File
@@ -0,0 +1,11 @@
export * from "./approval.js";
export * from "./attachments.js";
export * from "./credential-vault.js";
export * from "./errors.js";
export * from "./event-store.js";
export * from "./model-store.js";
export * from "./pi-event-adapter.js";
export * from "./safe-workspace.js";
export * from "./session.js";
export * from "./skills.js";
export * from "./tools.js";
+178
View File
@@ -0,0 +1,178 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import type { Model } from "@earendil-works/pi-ai";
import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { modelConnectionInputSchema, type ModelConnectionInput, type RedactedModelConnection } from "@agent-studio/shared";
import { HarnessError } from "./errors.js";
import { modelCredentialIdentity, type CredentialVault } from "./credential-vault.js";
interface Entry { input: ModelConnectionInput; apiKey?: string; runtime?: ModelRuntime; credentialStorage?: "memory" | "encrypted" | "environment" }
export interface ResolvedPiModelRuntime { runtime: ModelRuntime; model: Model<string>; supportsImages: boolean }
export interface ModelConnectionStore {
configure(sessionId: string, input: ModelConnectionInput): Promise<RedactedModelConnection>;
getRedacted(sessionId: string): RedactedModelConnection;
clearCredentials(sessionId: string): Promise<void>;
createRuntime(sessionId: string): Promise<ResolvedPiModelRuntime>;
remove(sessionId: string): Promise<void>;
}
const metadataHosts = new Set(["169.254.169.254", "100.100.100.200", "metadata.google.internal", "metadata.azure.internal"]);
function restrictedIp(address: string): boolean {
if (!isIP(address)) return true;
if (address === "::1" || address === "0.0.0.0" || address.startsWith("fe80:") || address.startsWith("fc") || address.startsWith("fd")) return true;
const parts = address.split(".").map(Number);
if (parts.length === 4) return parts[0] === 10 || parts[0] === 127 || parts[0] === 0 || (parts[0] === 169 && parts[1] === 254) || (parts[0] === 172 && (parts[1] ?? 0) >= 16 && (parts[1] ?? 0) <= 31) || (parts[0] === 192 && parts[1] === 168) || (parts[0] === 100 && (parts[1] ?? 0) >= 64 && (parts[1] ?? 0) <= 127);
return false;
}
export async function validateBaseUrl(input: string, allowPrivate = false, development = process.env.NODE_ENV !== "production"): Promise<string> {
let url: URL;
try { url = new URL(input); } catch { throw new HarnessError("INVALID_BASE_URL", "Base URL is not a valid URL", 400); }
if (url.username || url.password || url.hash) throw new HarnessError("INVALID_BASE_URL", "Base URL cannot contain credentials or a fragment", 400);
const hostname = url.hostname.replace(/^\[|\]$/g, "");
const developmentLoopback = development && url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(hostname);
if (url.protocol !== "https:" && !developmentLoopback) throw new HarnessError("UNSAFE_BASE_URL", "Base URL must use HTTPS (loopback HTTP is allowed only in development)", 400);
if (metadataHosts.has(hostname.toLowerCase())) throw new HarnessError("SSRF_BLOCKED", "Cloud metadata endpoints are not allowed", 400);
for (const key of url.searchParams.keys()) if (/key|token|secret|auth|password/i.test(key)) throw new HarnessError("CREDENTIAL_IN_URL", "Base URL must not contain credential query parameters", 400);
const addresses = isIP(hostname) ? [{ address: hostname }] : await lookup(hostname, { all: true, verbatim: true }).catch(() => { throw new HarnessError("BASE_URL_DNS_FAILED", "Base URL hostname could not be resolved", 400); });
if (!allowPrivate && !developmentLoopback && addresses.some(({ address }) => restrictedIp(address))) throw new HarnessError("SSRF_BLOCKED", "Base URL resolves to a private, loopback, or link-local address", 400);
return url.toString().replace(/\/$/, "");
}
export class InMemoryModelConnectionStore implements ModelConnectionStore {
private readonly entries = new Map<string, Entry>();
private environmentEntry: Entry | undefined;
constructor(private readonly env: NodeJS.ProcessEnv = process.env, private readonly vault?: CredentialVault) {}
async initialize(): Promise<void> {
this.environmentEntry = await this.resolveEnvironmentInput();
}
async configure(sessionId: string, raw: ModelConnectionInput): Promise<RedactedModelConnection> {
const input = modelConnectionInputSchema.parse(raw);
const baseUrl = input.baseUrl ? await validateBaseUrl(input.baseUrl, this.env.ALLOW_PRIVATE_LLM_BASE_URLS === "true") : undefined;
const previous = this.entries.get(sessionId);
if (previous?.runtime) await previous.runtime.removeRuntimeApiKey(previous.input.providerId).catch(() => undefined);
const normalized: ModelConnectionInput = { ...input, ...(baseUrl ? { baseUrl } : {}) };
const identity = modelCredentialIdentity(normalized);
let apiKey = input.apiKey;
let credentialStorage: Entry["credentialStorage"] = apiKey ? this.vault ? "encrypted" : "memory" : undefined;
if (apiKey && this.vault) await this.vault.set(identity, apiKey);
if (!apiKey && previous?.apiKey && modelCredentialIdentity(previous.input) === identity) {
apiKey = previous.apiKey;
credentialStorage = previous.credentialStorage;
}
if (!apiKey && this.vault) {
apiKey = await this.vault.get(identity);
if (apiKey) credentialStorage = "encrypted";
}
const entry: Entry = { input: normalized };
if (apiKey) entry.apiKey = apiKey;
if (credentialStorage) entry.credentialStorage = credentialStorage;
this.entries.set(sessionId, entry);
return this.redact(entry, "session");
}
getRedacted(sessionId: string): RedactedModelConnection {
const entry = this.entries.get(sessionId);
if (entry) return this.redact(entry, "session");
return this.environmentEntry ? this.redact(this.environmentEntry, "environment") : { configured: false, source: "missing", keyConfigured: false };
}
async clearCredentials(sessionId: string): Promise<void> {
let entry = this.entries.get(sessionId);
if (!entry && this.environmentEntry) {
entry = this.cloneEntry(this.environmentEntry);
this.entries.set(sessionId, entry);
}
if (!entry) return;
if (entry.runtime) await entry.runtime.removeRuntimeApiKey(entry.input.providerId).catch(() => undefined);
if (this.vault) await this.vault.delete(modelCredentialIdentity(entry.input));
delete entry.apiKey;
delete entry.credentialStorage;
}
async createRuntime(sessionId: string): Promise<ResolvedPiModelRuntime> {
let entry = this.entries.get(sessionId);
if (!entry && this.environmentEntry) {
entry = this.cloneEntry(this.environmentEntry);
this.entries.set(sessionId, entry);
}
if (!entry) throw new HarnessError("MODEL_NOT_CONFIGURED", "No model connection is configured", 422);
if (!entry.apiKey) throw new HarnessError("MODEL_CREDENTIAL_NOT_CONFIGURED", "The selected provider has no API key", 422);
const checkedBaseUrl = entry.input.baseUrl ? await validateBaseUrl(entry.input.baseUrl, this.env.ALLOW_PRIVATE_LLM_BASE_URLS === "true") : undefined;
const credentials = new InMemoryCredentialStore();
const runtime = await ModelRuntime.create({ credentials, modelsPath: null, refreshOnCreate: false, allowModelNetwork: false, signal: AbortSignal.timeout(10_000), modelRefreshTimeoutMs: 10_000 });
const defaultBaseUrl = checkedBaseUrl ?? (entry.input.api === "anthropic-messages" ? "https://api.anthropic.com" : "https://api.openai.com/v1");
runtime.registerProvider(entry.input.providerId, {
name: entry.input.displayName ?? entry.input.providerId,
baseUrl: defaultBaseUrl,
api: entry.input.api,
models: [{ id: entry.input.modelId, name: entry.input.displayName ?? entry.input.modelId, api: entry.input.api, baseUrl: defaultBaseUrl, reasoning: false, input: entry.input.supportsImages ? ["text", "image"] : ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: entry.input.contextWindow ?? 128_000, maxTokens: entry.input.maxTokens ?? 8_192 }],
});
await runtime.setRuntimeApiKey(entry.input.providerId, entry.apiKey);
const model = runtime.getModel(entry.input.providerId, entry.input.modelId);
if (!model || model.api !== entry.input.api) throw new HarnessError("MODEL_RESOLUTION_FAILED", "Configured model could not be resolved exactly", 422);
entry.runtime = runtime;
return { runtime, model, supportsImages: model.input.includes("image") };
}
async remove(sessionId: string): Promise<void> {
const entry = this.entries.get(sessionId);
if (entry?.runtime) await entry.runtime.removeRuntimeApiKey(entry.input.providerId).catch(() => undefined);
this.entries.delete(sessionId);
}
private async resolveEnvironmentInput(): Promise<Entry | undefined> {
const providerId = this.env.AGENT_PROVIDER;
const modelId = this.env.AGENT_MODEL;
if (!providerId && !modelId) return undefined;
if (!providerId || !modelId) throw new HarnessError("INVALID_ENV_MODEL_CONFIG", "AGENT_PROVIDER and AGENT_MODEL must be configured together", 500);
try {
const input = modelConnectionInputSchema.parse({
providerId,
modelId,
api: this.env.LLM_API ?? "openai-responses",
supportsImages: parseEnvironmentBoolean(this.env.AGENT_SUPPORTS_IMAGES, false, "AGENT_SUPPORTS_IMAGES"),
...(this.env.LLM_BASE_URL ? { baseUrl: this.env.LLM_BASE_URL } : {}),
...(this.env.AGENT_DISPLAY_NAME ? { displayName: this.env.AGENT_DISPLAY_NAME } : {}),
...(this.env.AGENT_CONTEXT_WINDOW ? { contextWindow: parseEnvironmentInteger(this.env.AGENT_CONTEXT_WINDOW, "AGENT_CONTEXT_WINDOW") } : {}),
...(this.env.AGENT_MAX_TOKENS ? { maxTokens: parseEnvironmentInteger(this.env.AGENT_MAX_TOKENS, "AGENT_MAX_TOKENS") } : {}),
});
const baseUrl = input.baseUrl ? await validateBaseUrl(input.baseUrl, this.env.ALLOW_PRIVATE_LLM_BASE_URLS === "true") : undefined;
const normalized: ModelConnectionInput = { ...input, ...(baseUrl ? { baseUrl } : {}) };
let apiKey = input.api === "anthropic-messages" ? this.env.ANTHROPIC_API_KEY : this.env.OPENAI_API_KEY;
let credentialStorage: Entry["credentialStorage"] = apiKey ? "environment" : undefined;
if (!apiKey && this.vault) {
apiKey = await this.vault.get(modelCredentialIdentity(normalized));
if (apiKey) credentialStorage = "encrypted";
}
const entry: Entry = { input: normalized };
if (apiKey) entry.apiKey = apiKey;
if (credentialStorage) entry.credentialStorage = credentialStorage;
return entry;
} catch (error) {
if (error instanceof HarnessError) throw error;
throw new HarnessError("INVALID_ENV_MODEL_CONFIG", "Environment model configuration is invalid", 500);
}
}
private cloneEntry(entry: Entry): Entry {
return { input: { ...entry.input }, ...(entry.apiKey ? { apiKey: entry.apiKey } : {}), ...(entry.credentialStorage ? { credentialStorage: entry.credentialStorage } : {}) };
}
private redact(entry: Entry, source: "session" | "environment"): RedactedModelConnection {
const { input, apiKey } = entry;
return { configured: true, source, providerId: input.providerId, api: input.api, modelId: input.modelId, supportsImages: input.supportsImages, keyConfigured: Boolean(apiKey), ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}), ...(input.displayName ? { displayName: input.displayName } : {}), ...(apiKey ? { keyHint: `••••${apiKey.slice(-4)}` } : {}), ...(entry.credentialStorage ? { credentialStorage: entry.credentialStorage } : {}) };
}
}
function parseEnvironmentBoolean(value: string | undefined, fallback: boolean, name: string): boolean {
if (value === undefined || value === "") return fallback;
if (value === "true") return true;
if (value === "false") return false;
throw new HarnessError("INVALID_ENV_MODEL_CONFIG", `${name} must be true or false`, 500);
}
function parseEnvironmentInteger(value: string, name: string): number {
if (!/^\d+$/.test(value)) throw new HarnessError("INVALID_ENV_MODEL_CONFIG", `${name} must be a positive integer`, 500);
return Number(value);
}
+30
View File
@@ -0,0 +1,30 @@
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import type { EventInput, HarnessEventType } from "@agent-studio/shared";
import { redactValue } from "./errors.js";
export interface PiAdapterContext { runId: string; assistantMessageId: string }
export type MappedEvent = { [T in HarnessEventType]: EventInput<T> }[HarnessEventType];
export function mapPiEvent(event: AgentSessionEvent, context: PiAdapterContext): MappedEvent[] {
switch (event.type) {
case "message_update":
if (event.assistantMessageEvent.type === "text_delta") return [{ type: "assistant.delta", runId: context.runId, payload: { messageId: context.assistantMessageId, delta: event.assistantMessageEvent.delta } }];
return [];
case "message_end":
if (event.message.role === "assistant") return [{ type: "assistant.completed", runId: context.runId, payload: { messageId: context.assistantMessageId } }];
return [];
case "tool_execution_start":
return [{ type: "tool.requested", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, arguments: redactValue(event.args) } }];
case "tool_execution_end":
return event.isError
? [{ type: "tool.failed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, error: "Tool execution failed" } }]
: [{ type: "tool.completed", runId: context.runId, payload: { toolCallId: event.toolCallId, toolName: event.toolName, result: summarizeToolResult(event.toolName, event.result) } }];
default:
return [];
}
}
function summarizeToolResult(toolName: string, result: unknown): unknown {
if (["read", "grep", "find", "ls"].includes(toolName)) return { status: "success" };
return redactValue(result);
}
+58
View File
@@ -0,0 +1,58 @@
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { access, lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
import path from "node:path";
import { HarnessError } from "./errors.js";
export class SafeWorkspace {
private constructor(public readonly root: string) {}
static async create(root: string): Promise<SafeWorkspace> {
await mkdir(root, { recursive: true, mode: 0o700 });
return new SafeWorkspace(await realpath(root));
}
private contains(candidate: string): boolean {
const relative = path.relative(this.root, candidate);
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
}
private async assertParentsNoSymlink(candidate: string): Promise<void> {
const relative = path.relative(this.root, path.dirname(candidate));
let current = this.root;
for (const segment of relative.split(path.sep).filter(Boolean)) {
current = path.join(current, segment);
const stat = await lstat(current);
if (stat.isSymbolicLink()) throw new HarnessError("PATH_SYMLINK_DENIED", "Symbolic links are not allowed", 403);
}
}
async resolveExisting(input: string): Promise<string> {
const absolute = path.resolve(this.root, input);
if (!this.contains(absolute)) throw new HarnessError("PATH_OUTSIDE_WORKSPACE", "Path is outside the session workspace", 403);
const canonical = await realpath(absolute);
if (!this.contains(canonical)) throw new HarnessError("PATH_OUTSIDE_WORKSPACE", "Path resolves outside the session workspace", 403);
const stat = await lstat(absolute);
if (stat.isSymbolicLink()) throw new HarnessError("PATH_SYMLINK_DENIED", "Symbolic links are not allowed", 403);
return canonical;
}
async assertReadPath(input: string): Promise<void> {
if (/(^|[/\\])(?:\.env(?:\.|$)|\.git|\.ssh)([/\\]|$)/i.test(input)) throw new HarnessError("PROTECTED_PATH", "Protected path is not readable", 403);
await this.resolveExisting(input);
}
async writeReport(content: string, title?: string): Promise<{ path: "report.md"; byteSize: number }> {
const target = path.join(this.root, "report.md");
if (!this.contains(target)) throw new HarnessError("PATH_OUTSIDE_WORKSPACE", "Unsafe report path", 403);
await this.assertParentsNoSymlink(target);
try { if ((await lstat(target)).isSymbolicLink()) throw new HarnessError("PATH_SYMLINK_DENIED", "Report path is a symbolic link", 403); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
const body = title ? `# ${title}\n\n${content}` : content;
const temporary = path.join(this.root, `.report-${randomUUID()}.tmp`);
const handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
try { await handle.writeFile(body, "utf8"); await handle.sync(); } finally { await handle.close(); }
try { await rename(temporary, target); } catch (error) { await unlink(temporary).catch(() => undefined); throw error; }
await access(target, constants.R_OK);
return { path: "report.md", byteSize: Buffer.byteLength(body) };
}
}
+303
View File
@@ -0,0 +1,303 @@
import { randomUUID } from "node:crypto";
import { mkdir, realpath, rm } from "node:fs/promises";
import path from "node:path";
import type { ImageContent } from "@earendil-works/pi-ai";
import {
SessionManager,
SettingsManager,
createAgentSession,
isToolCallEventType,
type AgentSession,
type AgentSessionEvent,
type InlineExtension,
} from "@earendil-works/pi-coding-agent";
import type { ModelConnectionInput, SendMessageInput } from "@agent-studio/shared";
import { InMemoryApprovalBroker, type ApprovalBroker } from "./approval.js";
import type { FileAttachmentStore } from "./attachments.js";
import { HarnessError, redactValue, safeErrorMessage } from "./errors.js";
import type { EventStore } from "./event-store.js";
import type { ModelConnectionStore } from "./model-store.js";
import { mapPiEvent } from "./pi-event-adapter.js";
import { SafeWorkspace } from "./safe-workspace.js";
import type { SkillCatalog } from "./skills.js";
import type { SkillIndexEntry } from "./skills.js";
import { createMockAdsMetricsTool, createSaveReportDraftTool } from "./tools.js";
export interface AgentSessionPort {
readonly modelId: string;
readonly supportsImages: boolean;
prompt(text: string, options?: { images?: unknown[] }): Promise<void>;
abort(): Promise<void>;
subscribe(listener: (event: unknown) => void): () => void;
dispose(): void;
}
export interface AgentSessionFactoryInput {
sessionId: string;
cwd: string;
workspace: SafeWorkspace;
getRun: () => { runId: string; signal: AbortSignal; assistantMessageId: string } | undefined;
skillRequested: (skill: SkillIndexEntry, toolCallId: string) => void;
skillLoaded: (skill: SkillIndexEntry, toolCallId: string) => void;
toolRequested: (toolCallId: string, toolName: string, argumentsValue: unknown) => void;
}
export interface AgentSessionFactory { create(input: AgentSessionFactoryInput): Promise<AgentSessionPort> }
class PiAgentSessionPort implements AgentSessionPort {
readonly modelId: string;
readonly supportsImages: boolean;
constructor(private readonly session: AgentSession) {
if (!session.model) throw new Error("Pi AgentSession was created without an explicit model");
this.modelId = session.model.id;
this.supportsImages = session.model.input.includes("image");
}
async prompt(text: string, options?: { images?: unknown[] }): Promise<void> {
const images = options?.images as ImageContent[] | undefined;
await this.session.prompt(text, images ? { images } : undefined);
}
abort(): Promise<void> { return this.session.abort(); }
subscribe(listener: (event: unknown) => void): () => void { return this.session.subscribe(listener); }
dispose(): void { this.session.dispose(); }
}
export class PiAgentSessionFactory implements AgentSessionFactory {
constructor(
private readonly fixturePath: string,
private readonly catalog: SkillCatalog,
private readonly models: ModelConnectionStore,
private readonly approvals: ApprovalBroker,
) {}
async create(input: AgentSessionFactoryInput): Promise<AgentSessionPort> {
const resolved = await this.models.createRuntime(input.sessionId);
const extension: InlineExtension = { name: "agent-studio-safety", hidden: true, factory: (pi) => {
const pendingSkills = new Map<string, SkillIndexEntry>();
pi.on("tool_call", async (event) => {
const run = input.getRun();
if (!run) return { block: true, reason: "No active Harness run" };
const known = new Set(["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft"]);
if (!known.has(event.toolName)) {
input.toolRequested(event.toolCallId, event.toolName, publicToolArguments(event.input, input.cwd));
return { block: true, reason: "Tool is not allowed by the Harness policy", terminate: true };
}
let matchedSkill: SkillIndexEntry | undefined;
if (isToolCallEventType("read", event)) {
const skill = await this.catalog.matchReadPath(event.input.path, input.cwd);
if (skill) {
matchedSkill = skill;
pendingSkills.set(event.toolCallId, skill);
input.skillRequested(skill, event.toolCallId);
} else await input.workspace.assertReadPath(event.input.path);
} else if (["grep", "find", "ls"].includes(event.toolName)) {
const maybePath = (event.input as Record<string, unknown>).path;
if (typeof maybePath === "string") await input.workspace.assertReadPath(maybePath);
}
input.toolRequested(event.toolCallId, event.toolName, publicToolArguments(event.input, input.cwd, matchedSkill));
if (event.toolName === "save_report_draft") {
const decision = await this.approvals.request({ sessionId: input.sessionId, runId: run.runId, toolCallId: event.toolCallId, toolName: event.toolName, arguments: event.input, riskLevel: "medium" }, run.signal);
if (decision.status !== "approved") return { block: true, reason: `Human approval ${decision.status}` };
}
return undefined;
});
pi.on("tool_result", async (event) => {
const run = input.getRun();
const skill = pendingSkills.get(event.toolCallId);
if (run && skill && !event.isError && event.toolName === "read") {
input.skillLoaded(skill, event.toolCallId);
pendingSkills.delete(event.toolCallId);
}
});
} };
const loader = await this.catalog.createSessionLoader(input.cwd, path.join(input.cwd, ".pi-agent"), [extension]);
const { session } = await createAgentSession({
cwd: input.cwd,
agentDir: path.join(input.cwd, ".pi-agent"),
modelRuntime: resolved.runtime,
model: resolved.model,
tools: ["read", "grep", "find", "ls", "mock_ads_metrics", "save_report_draft"],
customTools: [createMockAdsMetricsTool(this.fixturePath), createSaveReportDraftTool(input.workspace)],
resourceLoader: loader,
sessionManager: SessionManager.inMemory(input.cwd),
settingsManager: SettingsManager.inMemory(),
});
return new PiAgentSessionPort(session);
}
}
export interface HarnessSession {
sessionId: string;
cwd: string;
workspace: SafeWorkspace;
busy: boolean;
currentRun: { runId: string; controller: AbortController; assistantMessageId: string; cancelReason?: "user" | "timeout" | "session_deleted" | "shutdown" } | undefined;
}
export interface CreateSessionInput { id?: string }
export interface SessionRegistry {
create(input?: CreateSessionInput): Promise<HarnessSession>;
get(sessionId: string): HarnessSession | undefined;
delete(sessionId: string): Promise<void>;
}
interface InternalSession extends HarnessSession { agent: AgentSessionPort | undefined; unsubscribe: (() => void) | undefined; timeout: ReturnType<typeof setTimeout> | undefined; disposed: boolean; requestedToolCalls: Set<string> }
export class InMemorySessionRegistry implements SessionRegistry {
private readonly sessions = new Map<string, InternalSession>();
readonly approvals: ApprovalBroker;
constructor(
private readonly sessionsRoot: string,
private readonly events: EventStore,
private readonly attachments: FileAttachmentStore,
private readonly models: ModelConnectionStore,
private readonly catalog: SkillCatalog,
private factory: AgentSessionFactory | undefined,
approvalTimeoutMs = 60_000,
private readonly runTimeoutMs = 120_000,
) {
this.approvals = new InMemoryApprovalBroker(approvalTimeoutMs, (resolution) => {
this.events.append(resolution.request.sessionId, { type: "approval.resolved", runId: resolution.request.runId, payload: { approvalId: resolution.request.approvalId, toolCallId: resolution.request.toolCallId, status: resolution.decision.status, resolvedAt: resolution.decision.resolvedAt } });
}, (request) => {
this.events.append(request.sessionId, { type: "approval.required", runId: request.runId, payload: request });
});
}
setFactory(factory: AgentSessionFactory): void { this.factory = factory; }
async create(input: CreateSessionInput = {}): Promise<HarnessSession> {
const sessionId = input.id ?? `ses_${randomUUID()}`;
const cwd = path.join(this.sessionsRoot, sessionId);
await mkdir(cwd, { recursive: false, mode: 0o700 });
const workspace = await SafeWorkspace.create(cwd);
const session: InternalSession = { sessionId, cwd: workspace.root, workspace, busy: false, currentRun: undefined, agent: undefined, unsubscribe: undefined, timeout: undefined, disposed: false, requestedToolCalls: new Set() };
this.sessions.set(sessionId, session);
this.events.append(sessionId, { type: "session.started", payload: { cwd: `workspace/sessions/${sessionId}` } });
return session;
}
get(id: string): HarnessSession | undefined { return this.sessions.get(id); }
private internal(id: string): InternalSession { const session = this.sessions.get(id); if (!session) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); return session; }
async configureModel(id: string, input: ModelConnectionInput) {
const session = this.internal(id);
if (session.busy) throw new HarnessError("SESSION_BUSY", "Cannot change model configuration while a run is active", 409);
await this.disposeAgent(session);
const value = await this.models.configure(id, input);
this.events.append(id, { type: "model.configured", payload: { providerId: input.providerId, modelId: input.modelId, api: input.api, supportsImages: input.supportsImages } });
return value;
}
async clearCredentials(id: string): Promise<void> { const session = this.internal(id); if (session.busy) throw new HarnessError("SESSION_BUSY", "Cannot clear credentials while a run is active", 409); await this.disposeAgent(session); await this.models.clearCredentials(id); }
async send(id: string, message: SendMessageInput): Promise<{ runId: string }> {
const session = this.internal(id);
if (session.busy) throw new HarnessError("SESSION_BUSY", "A run is already active for this session", 409);
const configuredModel = this.models.getRedacted(id);
if (!configuredModel.configured) throw new HarnessError("MODEL_NOT_CONFIGURED", "No model connection is configured", 422);
if (!configuredModel.keyConfigured) throw new HarnessError("MODEL_CREDENTIAL_NOT_CONFIGURED", "The selected provider has no API key", 422);
const maxImages = Number(process.env.MAX_IMAGES_PER_MESSAGE ?? 4);
if (message.attachmentIds.length > maxImages) throw new HarnessError("TOO_MANY_ATTACHMENTS", `A message can include at most ${maxImages} images`, 413);
const selected = message.attachmentIds.map((attachmentId) => {
const item = this.attachments.getStored(id, attachmentId);
if (!item) throw new HarnessError("ATTACHMENT_NOT_FOUND", "Attachment not found for this session", 404);
return item;
});
const runId = `run_${randomUUID()}`;
const assistantMessageId = `msg_${randomUUID()}`;
const controller = new AbortController();
session.currentRun = { runId, controller, assistantMessageId };
session.busy = true;
try { await this.ensureAgent(session); } catch (error) { session.busy = false; session.currentRun = undefined; this.events.append(id, { type: "run.failed", runId, payload: { code: "AGENT_SESSION_CREATE_FAILED", message: safeErrorMessage(error) } }); throw error; }
if (selected.length > 0 && !session.agent?.supportsImages) {
session.busy = false;
session.currentRun = undefined;
throw new HarnessError("MODEL_DOES_NOT_SUPPORT_IMAGES", "The selected model does not support image input", 422);
}
const agent = session.agent;
if (!agent) throw new HarnessError("AGENT_SESSION_CREATE_FAILED", "Agent session was not created", 500);
this.events.append(id, { type: "user.message", runId, payload: { messageId: `msg_${randomUUID()}`, text: message.text, attachmentIds: message.attachmentIds } });
this.events.append(id, { type: "run.started", runId, payload: { modelId: agent.modelId } });
const images: ImageContent[] = await Promise.all(selected.map(async (item) => ({ type: "image" as const, mimeType: item.mediaType, data: await this.attachments.readBase64(id, item.attachmentId) })));
session.timeout = setTimeout(() => void this.cancel(id, "timeout"), this.runTimeoutMs);
void this.executeRun(session, message.text, images);
return { runId };
}
private async ensureAgent(session: InternalSession): Promise<void> {
if (session.agent) return;
if (!this.factory) throw new Error("Agent session factory is not configured");
session.agent = await this.factory.create({
sessionId: session.sessionId,
cwd: session.cwd,
workspace: session.workspace,
getRun: () => session.currentRun ? { runId: session.currentRun.runId, signal: session.currentRun.controller.signal, assistantMessageId: session.currentRun.assistantMessageId } : undefined,
skillRequested: (skill, toolCallId) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "skill.requested", runId: run.runId, payload: { name: skill.name, filePath: skill.filePath, toolCallId } }); },
skillLoaded: (skill, toolCallId) => { const run = session.currentRun; if (run) this.events.append(session.sessionId, { type: "skill.loaded", runId: run.runId, payload: { name: skill.name, filePath: skill.filePath, toolCallId } }); },
toolRequested: (toolCallId, toolName, argumentsValue) => { const run = session.currentRun; if (run && !session.requestedToolCalls.has(toolCallId)) { session.requestedToolCalls.add(toolCallId); this.events.append(session.sessionId, { type: "tool.requested", runId: run.runId, payload: { toolCallId, toolName, arguments: redactValue(argumentsValue) } }); } },
});
session.unsubscribe = session.agent.subscribe((event) => this.onPiEvent(session, event as AgentSessionEvent));
}
private onPiEvent(session: InternalSession, event: AgentSessionEvent): void {
const run = session.currentRun;
if (!run) return;
// The blocking Extension preflight is the single source for tool.requested.
// Pi's later execution-start event has the same toolCallId and would create
// duplicate assistant-ui Tool parts (and can expose pre-normalized paths).
if (event.type === "tool_execution_start") return;
for (const mapped of mapPiEvent(event, { runId: run.runId, assistantMessageId: run.assistantMessageId })) this.events.append(session.sessionId, mapped);
}
private async executeRun(session: InternalSession, text: string, images: ImageContent[]): Promise<void> {
const run = session.currentRun;
if (!run || !session.agent) return;
try {
await session.agent.prompt(text, images.length ? { images } : undefined);
if (!run.controller.signal.aborted) this.events.append(session.sessionId, { type: "run.completed", runId: run.runId, payload: {} });
} catch (error) {
if (!run.controller.signal.aborted) this.events.append(session.sessionId, { type: "run.failed", runId: run.runId, payload: { code: "AGENT_RUN_FAILED", message: safeErrorMessage(error) } });
} finally {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = undefined;
session.busy = false;
if (session.currentRun?.runId === run.runId) session.currentRun = undefined;
}
}
async cancel(id: string, reason: "user" | "timeout" | "session_deleted" | "shutdown" = "user"): Promise<void> {
const session = this.internal(id);
const run = session.currentRun;
if (!run || !session.busy || run.controller.signal.aborted) return;
run.cancelReason = reason;
run.controller.abort(reason);
this.approvals.cancelForSession(id);
await session.agent?.abort().catch(() => undefined);
this.events.append(id, { type: "run.cancelled", runId: run.runId, payload: { reason } });
}
async delete(id: string, endReason: "deleted" | "shutdown" = "deleted"): Promise<void> {
const session = this.internal(id);
if (session.busy) await this.cancel(id, endReason === "shutdown" ? "shutdown" : "session_deleted");
this.approvals.cancelForSession(id);
this.events.append(id, { type: "session.ended", payload: { reason: endReason } });
await this.disposeAgent(session);
await this.attachments.removeAll(id);
await this.models.remove(id);
const root = await realpath(this.sessionsRoot);
const target = await realpath(session.cwd);
const relative = path.relative(root, target);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new HarnessError("UNSAFE_SESSION_DELETE", "Refusing unsafe session directory deletion", 500);
await rm(target, { recursive: true, force: true });
session.disposed = true;
this.sessions.delete(id);
}
async shutdown(): Promise<void> { for (const id of [...this.sessions.keys()]) await this.delete(id, "shutdown"); }
private async disposeAgent(session: InternalSession): Promise<void> { session.unsubscribe?.(); session.unsubscribe = undefined; session.agent?.dispose(); session.agent = undefined; }
}
function publicToolArguments(value: unknown, cwd: string, skill?: SkillIndexEntry): unknown {
const redacted = redactValue(value);
if (!redacted || typeof redacted !== "object" || Array.isArray(redacted)) return redacted;
const result = { ...(redacted as Record<string, unknown>) };
for (const key of ["path", "cwd", "filePath"]) {
const candidate = result[key];
if (typeof candidate !== "string" || !path.isAbsolute(candidate)) continue;
if (skill && key === "path") result[key] = skill.filePath;
else {
const relative = path.relative(cwd, candidate);
result[key] = relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : "<protected-path>";
}
}
return result;
}
+91
View File
@@ -0,0 +1,91 @@
import { access, realpath, stat } from "node:fs/promises";
import path from "node:path";
import { DefaultResourceLoader, SettingsManager, type InlineExtension, type ResourceDiagnostic, type Skill } from "@earendil-works/pi-coding-agent";
import type { SkillCatalogItem } from "@agent-studio/shared";
export interface SkillIndexEntry extends SkillCatalogItem { canonicalPath: string }
export class SkillCatalog {
private items: SkillIndexEntry[] = [];
private loader?: DefaultResourceLoader;
private canonicalDirectories: string[] = [];
constructor(private readonly projectRoot: string, private readonly agentDir: string, private readonly envDirs = process.env.AGENT_SKILLS_DIRS) {}
async reload(): Promise<void> {
const diagnostics: Array<{ directory: string; diagnostic: ResourceDiagnostic }> = [];
const candidates = [path.join(this.projectRoot, ".agents", "skills"), path.join(this.projectRoot, ".pi", "skills")];
if (this.envDirs) candidates.push(...this.envDirs.split(path.delimiter).map((item) => item.trim()).filter(Boolean));
const canonical = new Map<string, string>();
for (const candidate of candidates) {
try {
const resolved = await realpath(path.resolve(candidate));
if (!(await stat(resolved)).isDirectory()) throw new Error("not a directory");
await access(resolved);
canonical.set(resolved, candidate);
} catch {
diagnostics.push({ directory: candidate, diagnostic: { type: "error", message: `Skill directory is missing, unreadable, or not a directory: ${this.displayPath(candidate)}`, path: candidate } });
}
}
this.loader = new DefaultResourceLoader({
cwd: this.projectRoot,
agentDir: this.agentDir,
settingsManager: SettingsManager.inMemory(),
additionalSkillPaths: [...canonical.keys()],
noExtensions: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
});
this.canonicalDirectories = [...canonical.keys()];
await this.loader.reload();
const discovered = this.loader.getSkills();
const byPath = new Map<string, SkillIndexEntry>();
for (const skill of discovered.skills) {
const skillPath = await realpath(skill.filePath).catch(() => path.resolve(skill.filePath));
const related = discovered.diagnostics.filter((item) => item.path && path.resolve(item.path) === path.resolve(skill.filePath));
byPath.set(skillPath, this.toItem(skill, skillPath, related));
}
for (const { directory, diagnostic } of diagnostics) {
const key = `diagnostic:${path.resolve(directory)}`;
byPath.set(key, { name: path.basename(directory) || "unknown", description: "Skill directory diagnostic", source: "environment", filePath: this.displayPath(directory), canonicalPath: key, diagnostics: [{ severity: "error", message: diagnostic.message }] });
}
for (const diagnostic of discovered.diagnostics.filter((item) => ![...byPath.values()].some((skill) => item.path && path.resolve(item.path) === path.resolve(skill.canonicalPath)))) {
const key = `diagnostic:${diagnostic.path ?? diagnostic.message}`;
byPath.set(key, { name: diagnostic.path ? path.basename(path.dirname(diagnostic.path)) : "unknown", description: "Invalid Skill", source: "pi-loader", filePath: this.displayPath(diagnostic.path ?? "unknown"), canonicalPath: key, diagnostics: [{ severity: diagnostic.type === "warning" ? "warning" : "error", message: diagnostic.message }] });
}
this.items = [...byPath.values()].sort((a, b) => a.name.localeCompare(b.name));
}
getResourceLoader(): DefaultResourceLoader {
if (!this.loader) throw new Error("Skill catalog has not been loaded");
return this.loader;
}
async createSessionLoader(cwd: string, agentDir: string, extensionFactories: InlineExtension[]): Promise<DefaultResourceLoader> {
const loader = new DefaultResourceLoader({
cwd,
agentDir,
settingsManager: SettingsManager.inMemory(),
additionalSkillPaths: this.canonicalDirectories,
extensionFactories,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
});
await loader.reload({ resolveProjectTrust: async () => true });
return loader;
}
list(): SkillCatalogItem[] { return this.items.map((item) => ({ name: item.name, description: item.description, source: item.source, filePath: item.filePath, diagnostics: item.diagnostics })); }
findCanonical(filePath: string): SkillIndexEntry | undefined { return this.items.find((item) => item.canonicalPath === filePath); }
async matchReadPath(inputPath: string, cwd: string): Promise<SkillIndexEntry | undefined> {
const canonical = await realpath(path.resolve(cwd, inputPath)).catch(() => undefined);
return canonical ? this.findCanonical(canonical) : undefined;
}
private toItem(skill: Skill, canonicalPath: string, diagnostics: ResourceDiagnostic[]): SkillIndexEntry {
return { name: skill.name, description: skill.description, source: skill.sourceInfo.source, filePath: this.displayPath(canonicalPath), canonicalPath, diagnostics: diagnostics.map((item) => ({ severity: item.type === "warning" ? "warning" : "error", message: item.message })) };
}
private displayPath(input: string): string {
const absolute = path.resolve(input);
const relative = path.relative(this.projectRoot, absolute);
return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : `<external>/${path.basename(absolute)}`;
}
}
+59
View File
@@ -0,0 +1,59 @@
import { readFile } from "node:fs/promises";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { SafeWorkspace } from "./safe-workspace.js";
interface CampaignInput { id: string; name: string; spend: number; impressions: number; clicks: number; conversions: number; revenue: number }
interface Fixture { accountId: string; currency: string; timeRange: { from: string; to: string }; campaigns: CampaignInput[] }
export interface CampaignMetrics extends CampaignInput { ctr: number; cpa: number; roas: number }
export interface AdsMetrics { accountId: string; days: number; currency: string; timeRange: { from: string; to: string }; spend: number; impressions: number; clicks: number; ctr: number; conversions: number; cpa: number; revenue: number; roas: number; campaigns: CampaignMetrics[] }
const round = (value: number, digits = 2): number => Number(value.toFixed(digits));
const ratio = (numerator: number, denominator: number, scale = 1): number => denominator === 0 ? 0 : round((numerator / denominator) * scale, 4);
export const mockAdsMetricsParameters = Type.Object({
accountId: Type.String({ minLength: 1, maxLength: 100, pattern: "^[a-zA-Z0-9_-]+$" }),
days: Type.Integer({ minimum: 1, maximum: 30 }),
}, { additionalProperties: false });
export async function getMockAdsMetrics(fixturePath: string, accountId: string, days: number): Promise<AdsMetrics> {
const fixture = JSON.parse(await readFile(fixturePath, "utf8")) as Fixture;
if (accountId !== fixture.accountId) throw new Error(`Unknown demo account: ${accountId}`);
const campaigns = fixture.campaigns.map((item) => ({ ...item, spend: round(item.spend), revenue: round(item.revenue), ctr: ratio(item.clicks, item.impressions, 100), cpa: ratio(item.spend, item.conversions), roas: ratio(item.revenue, item.spend) }));
const totals = campaigns.reduce((sum, item) => ({ spend: sum.spend + item.spend, impressions: sum.impressions + item.impressions, clicks: sum.clicks + item.clicks, conversions: sum.conversions + item.conversions, revenue: sum.revenue + item.revenue }), { spend: 0, impressions: 0, clicks: 0, conversions: 0, revenue: 0 });
return { accountId, days, currency: fixture.currency, timeRange: fixture.timeRange, spend: round(totals.spend), impressions: totals.impressions, clicks: totals.clicks, ctr: ratio(totals.clicks, totals.impressions, 100), conversions: totals.conversions, cpa: ratio(totals.spend, totals.conversions), revenue: round(totals.revenue), roas: ratio(totals.revenue, totals.spend), campaigns };
}
export function createMockAdsMetricsTool(fixturePath: string) {
return defineTool({
name: "mock_ads_metrics",
label: "Mock Ads Metrics",
description: "Read deterministic classroom advertising metrics for a demo account. This is not real platform data.",
promptSnippet: "Read classroom advertising metrics",
parameters: mockAdsMetricsParameters,
async execute(_toolCallId, params) {
const data = await getMockAdsMetrics(fixturePath, params.accountId, params.days);
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], details: data };
},
});
}
export const saveReportDraftParameters = Type.Object({
content: Type.String({ minLength: 1, maxLength: 100_000 }),
title: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
}, { additionalProperties: false });
export function createSaveReportDraftTool(workspace: SafeWorkspace) {
return defineTool({
name: "save_report_draft",
label: "Save Report Draft",
description: "Save a report draft to the fixed report.md file in this session workspace. Requires human approval.",
promptSnippet: "Save an approved report draft",
parameters: saveReportDraftParameters,
executionMode: "sequential",
async execute(_toolCallId, params) {
const result = await workspace.writeReport(params.content, params.title);
return { content: [{ type: "text", text: `Report draft saved to ${result.path} (${result.byteSize} bytes).` }], details: result };
},
});
}
@@ -0,0 +1,17 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import sharp from "sharp";
import { FileAttachmentStore } from "../src/index.js";
async function image(format: "jpeg" | "png" | "webp") { const pipeline = sharp({ create: { width: 4, height: 3, channels: 3, background: "#25f4ee" } }); return format === "jpeg" ? pipeline.jpeg().toBuffer() : format === "png" ? pipeline.png().toBuffer() : pipeline.webp().toBuffer(); }
function upload(filename: string, mimetype: string, data: Buffer) { return { filename, mimetype, stream: (async function* () { yield data; })() }; }
describe("FileAttachmentStore", () => {
it.each([["jpeg", "photo.jpg", "image/jpeg"], ["png", "photo.png", "image/png"], ["webp", "photo.webp", "image/webp"]] as const)("normalizes valid %s", async (format, name, mime) => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root); const item = await store.add("s", upload(name, mime, await image(format))); expect(item).toMatchObject({ sessionId: "s", mediaType: mime, width: 4, height: 3, status: "ready" }); });
it("rejects spoofed MIME and SVG", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root); await expect(store.add("s", upload("fake.jpg", "image/jpeg", Buffer.from("not jpeg")))).rejects.toMatchObject({ code: "ATTACHMENT_MAGIC_MISMATCH" }); await expect(store.add("s", upload("x.svg", "image/svg+xml", Buffer.from("<svg/>")))).rejects.toMatchObject({ code: "ATTACHMENT_TYPE_UNSUPPORTED" }); });
it("rejects an oversized stream before decoding", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root, 10); await expect(store.add("s", upload("x.jpg", "image/jpeg", Buffer.alloc(11)))).rejects.toMatchObject({ code: "ATTACHMENT_TOO_LARGE" }); });
it("isolates attachments across sessions", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root); const item = await store.add("one", upload("x.png", "image/png", await image("png"))); expect(store.get("two", item.attachmentId)).toBeUndefined(); });
it("removes all session attachment state", async () => { const root = await mkdtemp(path.join(tmpdir(), "studio-images-")); const store = new FileAttachmentStore(root); const item = await store.add("one", upload("x.png", "image/png", await image("png"))); await store.removeAll("one"); expect(store.get("one", item.attachmentId)).toBeUndefined(); });
});
+121
View File
@@ -0,0 +1,121 @@
import { mkdtemp, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
InMemoryApprovalBroker,
InMemoryEventStore,
SafeWorkspace,
SkillCatalog,
createSaveReportDraftTool,
getMockAdsMetrics,
mockAdsMetricsParameters,
redactValue,
validateBaseUrl,
} from "../src/index.js";
import { Value } from "typebox/value";
const projectRoot = path.resolve(import.meta.dirname, "../../..");
describe("InMemoryEventStore", () => {
it("allocates monotonic per-session sequences atomically", () => {
const store = new InMemoryEventStore();
const first = store.append("s", { type: "run.completed", runId: "r", payload: {} });
const second = store.append("s", { type: "run.completed", runId: "r", payload: {} });
expect([first.sequence, second.sequence]).toEqual([1, 2]);
expect(store.append("other", { type: "run.completed", runId: "r", payload: {} }).sequence).toBe(1);
});
it("replays by sequence and event id", () => {
const store = new InMemoryEventStore();
const one = store.append("s", { type: "run.completed", payload: {} });
store.append("s", { type: "run.completed", payload: {} });
expect(store.listAfter("s", { sequence: 1 }).events).toHaveLength(1);
expect(store.listAfter("s", { eventId: one.eventId }).events).toHaveLength(1);
});
it("reports a retained-history gap", () => {
const store = new InMemoryEventStore(2);
for (let index = 0; index < 4; index++) store.append("s", { type: "run.completed", payload: {} });
expect(store.listAfter("s", { sequence: 1 }).gap?.oldestAvailableSequence).toBe(3);
});
it("unsubscribes listeners", () => {
const store = new InMemoryEventStore(); const listener = vi.fn(); const unsubscribe = store.subscribe("s", listener); unsubscribe(); store.append("s", { type: "run.completed", payload: {} }); expect(listener).not.toHaveBeenCalled();
});
});
describe("ApprovalBroker", () => {
const request = { sessionId: "s", runId: "r", toolCallId: "t", toolName: "save_report_draft", arguments: { content: "x" }, riskLevel: "medium" as const };
it("approves and is idempotent for the same decision", async () => {
let id = ""; const broker = new InMemoryApprovalBroker(1_000, undefined, (value) => { id = value.approvalId; });
const pending = broker.request(request, new AbortController().signal); const first = broker.approve(id); const second = broker.approve(id);
expect((await pending).status).toBe("approved"); expect(second.decision).toEqual(first.decision);
});
it("denies and rejects a conflicting repeat", async () => {
let id = ""; const broker = new InMemoryApprovalBroker(1_000, undefined, (value) => { id = value.approvalId; }); const pending = broker.request(request, new AbortController().signal); broker.deny(id); expect((await pending).status).toBe("denied"); expect(() => broker.approve(id)).toThrow(/already resolved/i);
});
it("expires", async () => {
const broker = new InMemoryApprovalBroker(5); const decision = await broker.request(request, new AbortController().signal); expect(decision.status).toBe("expired");
});
it("cancels immediately with AbortSignal", async () => {
const controller = new AbortController(); const broker = new InMemoryApprovalBroker(1_000); const pending = broker.request(request, controller.signal); controller.abort(); expect((await pending).status).toBe("cancelled");
});
it("redacts sensitive arguments", () => {
expect(redactValue({ apiKey: "secret", nested: { authorization: "Bearer x" } })).toEqual({ apiKey: "[REDACTED]", nested: { authorization: "[REDACTED]" } });
});
});
describe("SafeWorkspace", () => {
it("writes only fixed report.md through an atomic operation", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); const result = await workspace.writeReport("正文", "报告"); expect(result.path).toBe("report.md"); expect(await readFile(path.join(root, "report.md"), "utf8")).toContain("# 报告");
});
it("rejects .. traversal and absolute escape", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); await expect(workspace.resolveExisting("../outside")).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" }); await expect(workspace.resolveExisting("/etc/passwd")).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" });
});
it("rejects a symlink escape", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); await symlink("/etc/passwd", path.join(root, "linked")); await expect(workspace.resolveExisting("linked")).rejects.toMatchObject({ code: "PATH_OUTSIDE_WORKSPACE" });
});
it("rejects protected paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); await writeFile(path.join(root, ".env"), "KEY=x"); await expect(workspace.assertReadPath(".env")).rejects.toMatchObject({ code: "PROTECTED_PATH" });
});
it("save tool schema exposes no output path", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-safe-")); const workspace = await SafeWorkspace.create(root); const tool = createSaveReportDraftTool(workspace); expect((tool.parameters as { properties: Record<string, unknown> }).properties).not.toHaveProperty("path");
});
});
describe("mock_ads_metrics", () => {
it("validates days in the TypeBox schema", () => {
expect(Value.Check(mockAdsMetricsParameters, { accountId: "demo-account", days: 7 })).toBe(true); expect(Value.Check(mockAdsMetricsParameters, { accountId: "demo-account", days: 31 })).toBe(false);
});
it("computes deterministic metrics and handles precision", async () => {
const metrics = await getMockAdsMetrics(path.join(projectRoot, "fixtures", "ads-account.json"), "demo-account", 7); expect(metrics.spend).toBe(4415.5); expect(metrics.campaigns).toHaveLength(3); expect(metrics.roas).toBeGreaterThan(4);
});
it("rejects unknown accounts", async () => {
await expect(getMockAdsMetrics(path.join(projectRoot, "fixtures", "ads-account.json"), "missing", 7)).rejects.toThrow("Unknown demo account");
});
});
describe("SkillCatalog", () => {
it("discovers .agents and .pi skills without reading bodies into the catalog", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await createSkill(root, ".agents/skills/one", "one", "First skill"); await createSkill(root, ".pi/skills/two", "two", "Second skill"); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); expect(catalog.list().filter((item) => ["one", "two"].includes(item.name))).toHaveLength(2); expect(JSON.stringify(catalog.list())).not.toContain("SECRET BODY");
});
it("loads path-delimited AGENT_SKILLS_DIRS and de-duplicates canonical paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const extra = path.join(root, "extra"); await createSkill(root, "extra/custom", "custom", "Extra skill"); const catalog = new SkillCatalog(root, path.join(root, "agent-dir"), [extra, extra].join(path.delimiter)); await catalog.reload(); expect(catalog.list().filter((item) => item.name === "custom")).toHaveLength(1);
});
it("turns an unreadable extra directory into a visible diagnostic", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent-dir"), path.join(root, "missing")); await catalog.reload(); expect(catalog.list().some((item) => item.diagnostics.some((diagnostic) => diagnostic.severity === "error"))).toBe(true);
});
it("matches requested skill reads by canonical path", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await createSkill(root, ".agents/skills/one", "one", "First skill"); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); expect((await catalog.matchReadPath(path.join(root, ".agents/skills/one/SKILL.md"), root))?.name).toBe("one"); expect(await catalog.matchReadPath("SKILL.md", root)).toBeUndefined();
});
});
describe("Base URL safety", () => {
it("rejects metadata, userinfo, dangerous protocols, and private HTTPS", async () => {
await expect(validateBaseUrl("https://169.254.169.254/latest", false, false)).rejects.toMatchObject({ code: "SSRF_BLOCKED" }); await expect(validateBaseUrl("https://user:pass@example.com", false, false)).rejects.toMatchObject({ code: "INVALID_BASE_URL" }); await expect(validateBaseUrl("file:///etc/passwd", false, false)).rejects.toMatchObject({ code: "UNSAFE_BASE_URL" }); await expect(validateBaseUrl("https://127.0.0.1/v1", false, false)).rejects.toMatchObject({ code: "SSRF_BLOCKED" });
});
it("allows explicit loopback HTTP only in development", async () => {
await expect(validateBaseUrl("http://localhost:11434/v1", false, true)).resolves.toContain("localhost"); await expect(validateBaseUrl("http://localhost:11434/v1", false, false)).rejects.toMatchObject({ code: "UNSAFE_BASE_URL" });
});
it("rejects credential query parameters", async () => { await expect(validateBaseUrl("https://8.8.8.8/v1?api_key=x", false, false)).rejects.toMatchObject({ code: "CREDENTIAL_IN_URL" }); });
});
async function createSkill(root: string, relative: string, name: string, description: string) { const directory = path.join(root, relative); await mkdir(directory, { recursive: true }); await writeFile(path.join(directory, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n\nSECRET BODY\n`); }
@@ -0,0 +1,101 @@
import { randomBytes } from "node:crypto";
import { mkdtemp, readFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { EncryptedFileCredentialVault, InMemoryModelConnectionStore, modelCredentialIdentity } from "../src/index.js";
const model = {
providerId: "training",
api: "openai-responses" as const,
baseUrl: "https://8.8.8.8/v1",
modelId: "training-model",
supportsImages: true,
};
describe("EncryptedFileCredentialVault", () => {
it("persists only authenticated ciphertext and decrypts after restart", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const filePath = path.join(root, "credentials.enc.json");
const masterKey = randomBytes(32).toString("base64");
const identity = modelCredentialIdentity(model);
const first = new EncryptedFileCredentialVault(filePath, masterKey);
await first.set(identity, "secret-model-key");
const raw = await readFile(filePath, "utf8");
expect(raw).not.toContain("secret-model-key");
expect((await stat(filePath)).mode & 0o777).toBe(0o600);
const restarted = new EncryptedFileCredentialVault(filePath, masterKey);
await expect(restarted.get(identity)).resolves.toBe("secret-model-key");
});
it("restores a credential for a new Web Session and preserves it when the old Session is removed", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const vault = new EncryptedFileCredentialVault(path.join(root, "credentials.enc.json"), randomBytes(32).toString("base64"));
const first = new InMemoryModelConnectionStore({}, vault);
await first.configure("session-one", { ...model, apiKey: "persistent-secret" });
expect(first.getRedacted("session-one")).toMatchObject({ keyConfigured: true, credentialStorage: "encrypted" });
await first.remove("session-one");
const restarted = new InMemoryModelConnectionStore({}, vault);
await restarted.configure("session-two", model);
expect(restarted.getRedacted("session-two")).toMatchObject({ keyConfigured: true, keyHint: "••••cret", credentialStorage: "encrypted" });
});
it("deletes the persisted credential only through Clear Credentials", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const vault = new EncryptedFileCredentialVault(path.join(root, "credentials.enc.json"), randomBytes(32).toString("base64"));
const store = new InMemoryModelConnectionStore({}, vault);
await store.configure("session-one", { ...model, apiKey: "persistent-secret" });
await store.clearCredentials("session-one");
const restarted = new InMemoryModelConnectionStore({}, vault);
await restarted.configure("session-two", model);
expect(restarted.getRedacted("session-two")).toMatchObject({ keyConfigured: false });
});
it("fails closed for an invalid or different master key", async () => {
expect(() => new EncryptedFileCredentialVault("unused", "not-a-32-byte-key")).toThrow(/32-byte key/);
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const filePath = path.join(root, "credentials.enc.json");
const identity = modelCredentialIdentity(model);
const first = new EncryptedFileCredentialVault(filePath, randomBytes(32).toString("base64"));
await first.set(identity, "secret-model-key");
const wrong = new EncryptedFileCredentialVault(filePath, randomBytes(32).toString("base64"));
await expect(wrong.get(identity)).rejects.toMatchObject({ code: "CREDENTIAL_DECRYPTION_FAILED" });
});
it("loads a strict environment default and restores its encrypted credential", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
const vault = new EncryptedFileCredentialVault(path.join(root, "credentials.enc.json"), randomBytes(32).toString("base64"));
await vault.set(modelCredentialIdentity(model), "environment-default-secret");
const store = new InMemoryModelConnectionStore({
AGENT_PROVIDER: model.providerId,
AGENT_MODEL: model.modelId,
LLM_API: model.api,
LLM_BASE_URL: model.baseUrl,
AGENT_DISPLAY_NAME: "Training Model",
AGENT_SUPPORTS_IMAGES: "true",
}, vault);
await store.initialize();
expect(store.getRedacted("fresh-session")).toMatchObject({ source: "environment", modelId: model.modelId, displayName: "Training Model", supportsImages: true, keyConfigured: true, credentialStorage: "encrypted" });
});
it("uses an environment API key without returning the raw credential", async () => {
const store = new InMemoryModelConnectionStore({
AGENT_PROVIDER: model.providerId,
AGENT_MODEL: model.modelId,
LLM_API: model.api,
LLM_BASE_URL: model.baseUrl,
OPENAI_API_KEY: "environment-api-secret",
});
await store.initialize();
const redacted = store.getRedacted("fresh-session");
expect(redacted).toMatchObject({ source: "environment", keyConfigured: true, keyHint: "••••cret", credentialStorage: "environment" });
expect(JSON.stringify(redacted)).not.toContain("environment-api-secret");
});
it("fails startup for partial or malformed environment defaults", async () => {
const partial = new InMemoryModelConnectionStore({ AGENT_PROVIDER: "openai" });
await expect(partial.initialize()).rejects.toMatchObject({ code: "INVALID_ENV_MODEL_CONFIG" });
const malformed = new InMemoryModelConnectionStore({ AGENT_PROVIDER: "openai", AGENT_MODEL: "model", AGENT_SUPPORTS_IMAGES: "yes" });
await expect(malformed.initialize()).rejects.toMatchObject({ code: "INVALID_ENV_MODEL_CONFIG" });
});
});
+122
View File
@@ -0,0 +1,122 @@
import { mkdtemp, mkdir, readFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import {
FileAttachmentStore,
InMemoryApprovalBroker,
InMemoryEventStore,
InMemoryModelConnectionStore,
InMemorySessionRegistry,
PiAgentSessionFactory,
SafeWorkspace,
SkillCatalog,
type AgentSessionFactory,
type AgentSessionFactoryInput,
type AgentSessionPort,
} from "../src/index.js";
class FakePort implements AgentSessionPort {
readonly modelId = "fake-model";
constructor(public readonly supportsImages = true) {}
listeners = new Set<(event: unknown) => void>();
promptOptions: { images?: unknown[] } | undefined;
disposed = false; aborted = false;
private finish: (() => void) | undefined;
prompt(_text: string, options?: { images?: unknown[] }): Promise<void> { this.promptOptions = options; return new Promise((resolve) => { this.finish = resolve; }); }
async abort(): Promise<void> { this.aborted = true; this.finish?.(); }
complete(): void { this.finish?.(); }
subscribe(listener: (event: unknown) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
dispose(): void { this.disposed = true; }
emit(event: unknown): void { for (const listener of this.listeners) listener(event); }
}
class FakeFactory implements AgentSessionFactory {
port: FakePort;
input?: AgentSessionFactoryInput;
constructor(supportsImages = true) { this.port = new FakePort(supportsImages); }
async create(input: AgentSessionFactoryInput): Promise<AgentSessionPort> { this.input = input; return this.port; }
}
async function setup(supportsImages = true) {
const root = await mkdtemp(path.join(tmpdir(), "studio-session-")); const sessionsRoot = path.join(root, "sessions"); await mkdir(sessionsRoot);
await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true });
const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload();
const events = new InMemoryEventStore(); const attachments = new FileAttachmentStore(sessionsRoot); const models = new InMemoryModelConnectionStore({}); const factory = new FakeFactory(supportsImages);
const registry = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, catalog, factory, 500, 5_000);
const session = await registry.create(); await registry.configureModel(session.sessionId, { providerId: "fake", api: "openai-responses", apiKey: "test-secret", modelId: "fake-model", supportsImages });
return { root, sessionsRoot, events, attachments, models, factory, registry, session };
}
describe("session lifecycle", () => {
it("creates a real Pi AgentSession for the exact configured model without model network access", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-real-pi-"));
await mkdir(path.join(root, ".agents/skills"), { recursive: true });
await mkdir(path.join(root, ".pi/skills"), { recursive: true });
const cwd = path.join(root, "session"); await mkdir(cwd);
const catalog = new SkillCatalog(root, path.join(root, "catalog")); await catalog.reload();
const models = new InMemoryModelConnectionStore({ ALLOW_PRIVATE_LLM_BASE_URLS: "false" });
await models.configure("ses_real", { providerId: "training", api: "openai-responses", baseUrl: "http://127.0.0.1:9/v1", apiKey: "fake-no-network", modelId: "training-model", supportsImages: true });
const workspace = await SafeWorkspace.create(cwd);
const factory = new PiAgentSessionFactory(path.join(root, "unused-fixture.json"), catalog, models, new InMemoryApprovalBroker());
const port = await factory.create({ sessionId: "ses_real", cwd, workspace, getRun: () => undefined, skillRequested: vi.fn(), skillLoaded: vi.fn(), toolRequested: vi.fn() });
expect(port.modelId).toBe("training-model"); expect(port.supportsImages).toBe(true); port.dispose(); await models.remove("ses_real");
});
it("rejects unconfigured models instead of falling back", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-session-")); const sessionsRoot = path.join(root, "sessions"); await mkdir(sessionsRoot); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent")); await catalog.reload(); const models = new InMemoryModelConnectionStore({}); const registry = new InMemorySessionRegistry(sessionsRoot, new InMemoryEventStore(), new FileAttachmentStore(sessionsRoot), models, catalog, new FakeFactory()); const session = await registry.create(); await expect(registry.send(session.sessionId, { text: "hello", attachmentIds: [] })).rejects.toMatchObject({ code: "MODEL_NOT_CONFIGURED" }); await expect(models.createRuntime(session.sessionId)).rejects.toMatchObject({ code: "MODEL_NOT_CONFIGURED" });
});
it("accepts a run without waiting for prompt completion and rejects concurrency", async () => {
const context = await setup(); const result = await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); expect(result.runId).toMatch(/^run_/); expect(context.session.busy).toBe(true); await expect(context.registry.send(context.session.sessionId, { text: "again", attachmentIds: [] })).rejects.toMatchObject({ code: "SESSION_BUSY" }); context.factory.port.complete(); await vi.waitFor(() => expect(context.session.busy).toBe(false));
});
it("cancels an active run and disposes on deletion", async () => {
const context = await setup(); await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); await context.registry.cancel(context.session.sessionId, "user"); expect(context.factory.port.aborted).toBe(true); await vi.waitFor(() => expect(context.session.busy).toBe(false)); await context.registry.delete(context.session.sessionId); expect(context.factory.port.disposed).toBe(true); await expect(stat(context.session.cwd)).rejects.toThrow();
});
it("maps fake Pi deltas and tool events centrally", async () => {
const context = await setup(); const { runId } = await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); context.factory.port.emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "流式" } }); context.factory.input?.toolRequested("tool_1", "mock_ads_metrics", { accountId: "demo-account", days: 7 }); context.factory.port.emit({ type: "tool_execution_start", toolCallId: "tool_1", toolName: "mock_ads_metrics", args: {} }); context.factory.port.emit({ type: "tool_execution_end", toolCallId: "tool_1", toolName: "mock_ads_metrics", result: { ok: true }, isError: false }); context.factory.port.complete(); await vi.waitFor(() => expect(context.session.busy).toBe(false)); const runEvents = context.events.listAfter(context.session.sessionId).events.filter((event) => event.runId === runId); expect(runEvents.map((event) => event.type)).toContain("assistant.delta"); expect(runEvents.filter((event) => event.type === "tool.requested")).toHaveLength(1); expect(runEvents.map((event) => event.type)).toContain("tool.completed");
});
it("uses Extension preflight as the sole tool request source when Pi start arrives first", async () => {
const context = await setup(); const { runId } = await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); context.factory.port.emit({ type: "tool_execution_start", toolCallId: "tool_race", toolName: "read", args: { path: "/absolute/private/path" } }); context.factory.input?.toolRequested("tool_race", "read", { path: ".agents/skills/ads-analysis/SKILL.md" }); context.factory.port.complete(); await vi.waitFor(() => expect(context.session.busy).toBe(false)); const requests = context.events.listAfter(context.session.sessionId).events.filter((event) => event.runId === runId && event.type === "tool.requested"); expect(requests).toHaveLength(1); expect(requests[0]?.payload.arguments).toEqual({ path: ".agents/skills/ads-analysis/SKILL.md" });
});
it("derives skill requested/loaded callbacks with the same toolCallId", async () => {
const context = await setup(); await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); const skill = { name: "ads-analysis", description: "x", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", canonicalPath: "/tmp/SKILL.md", diagnostics: [] }; context.factory.input?.skillRequested(skill, "read_1"); context.factory.input?.skillLoaded(skill, "read_1"); const skillEvents = context.events.listAfter(context.session.sessionId).events.filter((event) => event.type.startsWith("skill.")); expect(skillEvents.map((event) => event.type)).toEqual(["skill.requested", "skill.loaded"]); context.factory.port.complete();
});
});
describe("multimodal session path", () => {
it("maps a normalized image to the installed Pi ImageContent shape", async () => {
const context = await setup(true); const data = await sharp({ create: { width: 3, height: 2, channels: 3, background: "red" } }).png().toBuffer(); const item = await context.attachments.add(context.session.sessionId, { filename: "x.png", mimetype: "image/png", stream: (async function* () { yield data; })() }); await context.registry.send(context.session.sessionId, { text: "看图", attachmentIds: [item.attachmentId] }); await vi.waitFor(() => expect(context.factory.port.promptOptions).toBeDefined()); const imagePart = context.factory.port.promptOptions?.images?.[0] as { type: string; mimeType: string; data: string }; expect(imagePart).toMatchObject({ type: "image", mimeType: "image/png" }); expect(imagePart.data).toBe(Buffer.from(imagePart.data, "base64").toString("base64")); const serializedEvents = JSON.stringify(context.events.listAfter(context.session.sessionId).events); expect(serializedEvents).not.toContain(imagePart.data); context.factory.port.complete();
});
it("returns 422 semantics for a text-only model", async () => {
const context = await setup(false); const data = await sharp({ create: { width: 3, height: 2, channels: 3, background: "red" } }).png().toBuffer(); const item = await context.attachments.add(context.session.sessionId, { filename: "x.png", mimetype: "image/png", stream: (async function* () { yield data; })() }); await expect(context.registry.send(context.session.sessionId, { text: "看图", attachmentIds: [item.attachmentId] })).rejects.toMatchObject({ code: "MODEL_DOES_NOT_SUPPORT_IMAGES", statusCode: 422 });
});
it("rejects too many images before creating a run", async () => { const context = await setup(); const ids = Array.from({ length: 5 }, (_, index) => `att_${index}`); await expect(context.registry.send(context.session.sessionId, { text: "x", attachmentIds: ids })).rejects.toMatchObject({ code: "TOO_MANY_ATTACHMENTS" }); });
it("cleans attachments when deleting the session", async () => { const context = await setup(); const data = await sharp({ create: { width: 2, height: 2, channels: 3, background: "blue" } }).png().toBuffer(); const item = await context.attachments.add(context.session.sessionId, { filename: "x.png", mimetype: "image/png", stream: (async function* () { yield data; })() }); await context.registry.delete(context.session.sessionId); expect(context.attachments.get(context.session.sessionId, item.attachmentId)).toBeUndefined(); });
});
describe("fake Pi integration flow", () => {
it("runs message → tool → approval → report → completion without a real model", async () => {
const context = await setup(true);
const { runId } = await context.registry.send(context.session.sessionId, { text: "生成报告", attachmentIds: [] });
context.factory.port.emit({ type: "message_update", message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", delta: "正在分析" } });
context.factory.input?.toolRequested("metrics_1", "mock_ads_metrics", { accountId: "demo-account", days: 7 });
context.factory.port.emit({ type: "tool_execution_end", toolCallId: "metrics_1", toolName: "mock_ads_metrics", result: { spend: 1 }, isError: false });
context.factory.input?.toolRequested("save_1", "save_report_draft", { content: "报告正文" });
const signal = context.session.currentRun?.controller.signal;
expect(signal).toBeDefined();
const pending = context.registry.approvals.request({ sessionId: context.session.sessionId, runId, toolCallId: "save_1", toolName: "save_report_draft", arguments: { content: "报告正文" }, riskLevel: "medium" }, signal!);
const required = context.events.listAfter(context.session.sessionId).events.findLast((event) => event.type === "approval.required");
expect(required?.type).toBe("approval.required");
if (!required || required.type !== "approval.required") throw new Error("approval event missing");
context.registry.approvals.approve(required.payload.approvalId);
expect((await pending).status).toBe("approved");
await context.session.workspace.writeReport("报告正文");
context.factory.port.emit({ type: "tool_execution_end", toolCallId: "save_1", toolName: "save_report_draft", result: { path: "report.md" }, isError: false });
context.factory.port.complete();
await vi.waitFor(() => expect(context.session.busy).toBe(false));
expect(await readFile(path.join(context.session.cwd, "report.md"), "utf8")).toBe("报告正文");
const types = context.events.listAfter(context.session.sessionId).events.map((event) => event.type);
expect(types.indexOf("approval.required")).toBeLessThan(types.indexOf("approval.resolved"));
expect(types.at(-1)).toBe("run.completed");
await context.registry.delete(context.session.sessionId);
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" },
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@agent-studio/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { ".": { "types": "./src/index.ts", "development": "./src/index.ts", "import": "./dist/index.js", "default": "./dist/index.js" } },
"scripts": { "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.json" },
"dependencies": { "zod": "^4.1.13" },
"devDependencies": { "typescript": "^5.9.3" }
}
+51
View File
@@ -0,0 +1,51 @@
import { z } from "zod";
import { approvalRequestSchema, imageAttachmentSchema, llmApiSchema } from "./schemas.js";
const baseShape = {
eventId: z.string(),
sessionId: z.string(),
runId: z.string().optional(),
timestamp: z.string().datetime(),
sequence: z.number().int().positive(),
};
const event = <T extends string, S extends z.ZodType>(type: T, payload: S) =>
z.object({ ...baseShape, type: z.literal(type), payload }).strict();
const empty = z.object({}).strict();
const errorPayload = z.object({ code: z.string(), message: z.string() }).strict();
const toolBase = z.object({ toolCallId: z.string(), toolName: z.string(), arguments: z.unknown().optional() }).strict();
export const harnessEventSchema = z.discriminatedUnion("type", [
event("session.started", z.object({ cwd: z.string() }).strict()),
event("session.ended", z.object({ reason: z.enum(["deleted", "shutdown", "error"]) }).strict()),
event("user.message", z.object({ messageId: z.string(), text: z.string(), attachmentIds: z.array(z.string()) }).strict()),
event("assistant.delta", z.object({ messageId: z.string(), delta: z.string() }).strict()),
event("assistant.completed", z.object({ messageId: z.string() }).strict()),
event("skill.requested", z.object({ name: z.string(), filePath: z.string(), toolCallId: z.string() }).strict()),
event("skill.loaded", z.object({ name: z.string(), filePath: z.string(), toolCallId: z.string() }).strict()),
event("tool.requested", toolBase),
event("tool.completed", z.object({ toolCallId: z.string(), toolName: z.string(), result: z.unknown().optional() }).strict()),
event("tool.failed", z.object({ toolCallId: z.string(), toolName: z.string(), error: z.string() }).strict()),
event("approval.required", approvalRequestSchema),
event("approval.resolved", z.object({ approvalId: z.string(), toolCallId: z.string(), status: z.enum(["approved", "denied", "expired", "cancelled"]), resolvedAt: z.string().datetime() }).strict()),
event("attachment.uploaded", imageAttachmentSchema.omit({ sessionId: true })),
event("attachment.rejected", z.object({ name: z.string(), code: z.string(), message: z.string() }).strict()),
event("model.configured", z.object({ providerId: z.string(), modelId: z.string(), api: llmApiSchema, supportsImages: z.boolean() }).strict()),
event("model.validation_failed", errorPayload),
event("run.started", z.object({ modelId: z.string() }).strict()),
event("run.cancelled", z.object({ reason: z.enum(["user", "timeout", "session_deleted", "shutdown"]) }).strict()),
event("run.completed", empty),
event("run.failed", errorPayload),
]);
export type HarnessEvent = z.infer<typeof harnessEventSchema>;
export type HarnessEventType = HarnessEvent["type"];
export type EventOfType<T extends HarnessEventType> = Extract<HarnessEvent, { type: T }>;
export type EventInput<T extends HarnessEventType> = Omit<EventOfType<T>, "eventId" | "sessionId" | "timestamp" | "sequence">;
export const sseControlEventSchema = z.object({
type: z.literal("stream.gap"),
oldestAvailableSequence: z.number().int().positive(),
requestedSequence: z.number().int().nonnegative(),
}).strict();
+2
View File
@@ -0,0 +1,2 @@
export * from "./schemas.js";
export * from "./events.js";
+91
View File
@@ -0,0 +1,91 @@
import { z } from "zod";
export const llmApiSchema = z.enum(["openai-responses", "openai-completions", "anthropic-messages"]);
export const modelConnectionInputSchema = z.object({
providerId: z.string().trim().min(1).max(80).regex(/^[a-zA-Z0-9._-]+$/),
api: llmApiSchema,
baseUrl: z.string().trim().url().max(2048).optional(),
apiKey: z.string().min(1).max(8192).optional(),
modelId: z.string().trim().min(1).max(200),
displayName: z.string().trim().min(1).max(200).optional(),
supportsImages: z.boolean(),
contextWindow: z.number().int().min(1_024).max(10_000_000).optional(),
maxTokens: z.number().int().min(1).max(1_000_000).optional(),
}).strict();
export type ModelConnectionInput = z.infer<typeof modelConnectionInputSchema>;
export const persistedModelConnectionSchema = modelConnectionInputSchema.omit({ apiKey: true });
export type PersistedModelConnection = z.infer<typeof persistedModelConnectionSchema>;
export const redactedModelConnectionSchema = z.object({
configured: z.boolean(),
source: z.enum(["session", "environment", "missing"]),
providerId: z.string().optional(),
api: llmApiSchema.optional(),
baseUrl: z.string().optional(),
modelId: z.string().optional(),
displayName: z.string().optional(),
supportsImages: z.boolean().optional(),
keyConfigured: z.boolean(),
keyHint: z.string().optional(),
credentialStorage: z.enum(["memory", "encrypted", "environment"]).optional(),
}).strict();
export type RedactedModelConnection = z.infer<typeof redactedModelConnectionSchema>;
export const sendMessageSchema = z.object({
text: z.string().max(100_000).default(""),
attachmentIds: z.array(z.string().regex(/^att_[a-zA-Z0-9_-]+$/)).max(16).default([]),
}).strict().refine((value) => value.text.trim().length > 0 || value.attachmentIds.length > 0, {
message: "Text or at least one attachment is required",
});
export type SendMessageInput = z.infer<typeof sendMessageSchema>;
export const imageAttachmentSchema = z.object({
attachmentId: z.string(),
sessionId: z.string(),
name: z.string(),
mediaType: z.enum(["image/jpeg", "image/png", "image/webp"]),
byteSize: z.number().int().nonnegative(),
width: z.number().int().positive(),
height: z.number().int().positive(),
status: z.literal("ready"),
}).strict();
export type ImageAttachment = z.infer<typeof imageAttachmentSchema>;
export const approvalRequestSchema = z.object({
approvalId: z.string(),
sessionId: z.string(),
runId: z.string(),
toolCallId: z.string(),
toolName: z.string(),
arguments: z.unknown(),
riskLevel: z.enum(["low", "medium", "high"]),
createdAt: z.string().datetime(),
expiresAt: z.string().datetime(),
status: z.enum(["pending", "approved", "denied", "expired", "cancelled"]),
}).strict();
export type ApprovalRequest = z.infer<typeof approvalRequestSchema>;
export const apiErrorSchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
requestId: z.string(),
details: z.record(z.string(), z.unknown()),
}).strict(),
}).strict();
export type ApiError = z.infer<typeof apiErrorSchema>;
export const skillDiagnosticSchema = z.object({
severity: z.enum(["warning", "error"]),
message: z.string(),
}).strict();
export const skillCatalogItemSchema = z.object({
name: z.string(),
description: z.string(),
source: z.string(),
filePath: z.string(),
diagnostics: z.array(skillDiagnosticSchema),
}).strict();
export type SkillCatalogItem = z.infer<typeof skillCatalogItemSchema>;
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { apiErrorSchema, harnessEventSchema, modelConnectionInputSchema, persistedModelConnectionSchema, sendMessageSchema } from "../src/index.js";
describe("shared boundary schemas", () => {
it("accepts a typed HarnessEvent variant", () => {
expect(harnessEventSchema.parse({ eventId: "evt_1", sessionId: "ses_1", runId: "run_1", timestamp: new Date().toISOString(), sequence: 1, type: "assistant.delta", payload: { messageId: "msg_1", delta: "Hi" } }).type).toBe("assistant.delta");
});
it("rejects the wrong discriminated payload", () => {
expect(() => harnessEventSchema.parse({ eventId: "e", sessionId: "s", timestamp: new Date().toISOString(), sequence: 1, type: "run.completed", payload: { unexpected: true } })).toThrow();
});
it("strictly validates model configuration", () => {
expect(() => modelConnectionInputSchema.parse({ providerId: "openai", api: "made-up", modelId: "x", supportsImages: false })).toThrow();
expect(() => modelConnectionInputSchema.parse({ providerId: "openai", api: "openai-responses", modelId: "x", supportsImages: false, headers: {} })).toThrow();
});
it("accepts valid model configuration", () => {
expect(modelConnectionInputSchema.parse({ providerId: "openai", api: "openai-responses", modelId: "gpt", supportsImages: true }).modelId).toBe("gpt");
});
it("persists only non-sensitive model configuration", () => {
const value = { providerId: "openai", api: "openai-responses", modelId: "gpt", supportsImages: true };
expect(persistedModelConnectionSchema.parse(value)).toEqual(value);
expect(() => persistedModelConnectionSchema.parse({ ...value, apiKey: "secret" })).toThrow();
});
it("requires text or attachments", () => {
expect(() => sendMessageSchema.parse({ text: "", attachmentIds: [] })).toThrow();
expect(sendMessageSchema.parse({ text: "", attachmentIds: ["att_ok"] }).attachmentIds).toEqual(["att_ok"]);
});
it("rejects malformed attachment ids", () => {
expect(() => sendMessageSchema.parse({ text: "x", attachmentIds: ["../escape"] })).toThrow();
});
it("validates the unified error envelope", () => {
expect(apiErrorSchema.parse({ error: { code: "SESSION_NOT_FOUND", message: "Session not found", requestId: "req_1", details: {} } }).error.code).toBe("SESSION_NOT_FOUND");
});
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" },
"include": ["src"]
}
+6777
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
packages:
- apps/*
- packages/*
allowBuilds:
'@google/genai': true
esbuild: true
protobufjs: true
sharp: true
minimumReleaseAgeExclude:
- '@assistant-ui/core@0.3.16'
- '@assistant-ui/react@0.15.17'
- '@assistant-ui/store@0.3.11'
- '@assistant-ui/tap@0.9.15'
- assistant-cloud@0.1.42
- assistant-stream@0.3.40
- safe-content-frame@0.0.28
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"useUnknownInCatchVariables": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
}
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environmentMatchGlobs: [["apps/web/**", "jsdom"]],
setupFiles: ["./vitest.setup.ts"],
coverage: { reporter: ["text", "html"] },
},
});
+11
View File
@@ -0,0 +1,11 @@
import "@testing-library/jest-dom/vitest";
if (typeof window !== "undefined") {
class TestResizeObserver implements ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
globalThis.ResizeObserver = TestResizeObserver;
if (!HTMLElement.prototype.scrollTo) HTMLElement.prototype.scrollTo = () => undefined;
}