feat(contracts): 客户端与服务端协议 v1 及事件模型(A1.7)

JSON Schema 为规范本体(envelope / events / receipt / capability / session / approval / error),TS 镜像在 contracts/events.ts 与 contracts/protocol.ts,人读版本在 docs/spec/protocol.md。11 类事件(message.delta、message.completed、tool.call、tool.result、execution.updated、approval.requested、approval.resolved、run.state、session.updated、error、heartbeat)带会话内单调 seq,支持 Last-Event-ID 断点续传;HTTP 端点覆盖健康检查、身份、项目、会话、发消息、取消、能力清单、执行状态与审批响应。漂移检查卡三处一致:schema、TS、文档;样例共 11 条事件 + 5 条 HTTP。
This commit is contained in:
zh 2026-09-23 20:00:47 +08:00
parent 433684fd79
commit 2023e5312b
17 changed files with 837 additions and 18 deletions

115
contracts/events.ts Normal file
View File

@ -0,0 +1,115 @@
/**
* 协议 v1 的事件模型(JSON Schema 为准,这里是 TS 镜像)。
*
* 界面只依赖这一套事件:引擎换 Pi 还是 Codex,事件不变(架构设计 5.2)。
* 改事件必须同时改:contracts/schemas/events.schema.json、本文件、docs/spec/protocol.md。
*/
import type { ExecutionReceipt } from './protocol.js';
export const PROTOCOL_VERSION = 1;
/** 所有事件共有的信封字段 */
export interface EventEnvelope<TType extends string, TData> {
/** 协议版本;改协议就加版本,不改这一版 */
v: typeof PROTOCOL_VERSION;
/** 会话内单调递增:断点续传与去重靠它 */
seq: number;
/** 整数毫秒(ADR-0007) */
ts: number;
sessionId: string;
/** 一次运行(回合)的标识 */
runId?: string;
type: TType;
data: TData;
}
export type ExecutionStatus = ExecutionReceipt['status'];
export interface MessageDeltaData {
messageId: string;
text: string;
}
export interface MessageCompletedData {
messageId: string;
finishReason?: 'stop' | 'cancelled' | 'error' | 'length';
}
export interface ToolCallData {
callId: string;
capabilityId: string;
/** 参数摘要,不是参数明文 */
argsDigest: string;
}
export interface ToolResultData {
callId: string;
executionId: string;
status: ExecutionStatus;
}
export interface ExecutionUpdatedData {
executionId: string;
status: ExecutionStatus;
errorKind?: string;
}
export interface ApprovalRequestedData {
approvalId: string;
capabilityId: string;
paramsDigest: string;
/** 确认卡正文:影响范围与关键参数摘要 */
impactSummary: string;
/** 目标数据版本;版本变了确认就失效 */
revision: number;
expiresAt: number;
}
export interface ApprovalResolvedData {
approvalId: string;
decision: 'approved' | 'rejected' | 'expired';
}
export interface RunStateData {
state: 'idle' | 'running' | 'cancelling' | 'cancelled';
}
export interface SessionUpdatedData {
title?: string;
status?: 'active' | 'archived';
}
export interface ErrorData {
code: string;
message: string;
retryable: boolean;
errorKind?: string;
}
export type MessageDeltaEvent = EventEnvelope<'message.delta', MessageDeltaData>;
export type MessageCompletedEvent = EventEnvelope<'message.completed', MessageCompletedData>;
export type ToolCallEvent = EventEnvelope<'tool.call', ToolCallData>;
export type ToolResultEvent = EventEnvelope<'tool.result', ToolResultData>;
export type ExecutionUpdatedEvent = EventEnvelope<'execution.updated', ExecutionUpdatedData>;
export type ApprovalRequestedEvent = EventEnvelope<'approval.requested', ApprovalRequestedData>;
export type ApprovalResolvedEvent = EventEnvelope<'approval.resolved', ApprovalResolvedData>;
export type RunStateEvent = EventEnvelope<'run.state', RunStateData>;
export type SessionUpdatedEvent = EventEnvelope<'session.updated', SessionUpdatedData>;
export type ErrorEvent = EventEnvelope<'error', ErrorData>;
export type HeartbeatEvent = EventEnvelope<'heartbeat', Record<string, never>>;
/** 平台事件:客户端唯一认识的事件形态 */
export type PlatformEvent =
| MessageDeltaEvent
| MessageCompletedEvent
| ToolCallEvent
| ToolResultEvent
| ExecutionUpdatedEvent
| ApprovalRequestedEvent
| ApprovalResolvedEvent
| RunStateEvent
| SessionUpdatedEvent
| ErrorEvent
| HeartbeatEvent;
export type PlatformEventType = PlatformEvent['type'];

View File

@ -0,0 +1,11 @@
{"v":1,"seq":1,"ts":1757404800000,"sessionId":"s-1","runId":"r-1","type":"message.delta","data":{"messageId":"m-1","text":"正在核算产能"}}
{"v":1,"seq":2,"ts":1757404800100,"sessionId":"s-1","runId":"r-1","type":"message.completed","data":{"messageId":"m-1","finishReason":"stop"}}
{"v":1,"seq":3,"ts":1757404800200,"sessionId":"s-1","runId":"r-1","type":"tool.call","data":{"callId":"c-1","capabilityId":"aps.schedule.preview","argsDigest":"sha256:9f2a"}}
{"v":1,"seq":4,"ts":1757404800300,"sessionId":"s-1","runId":"r-1","type":"tool.result","data":{"callId":"c-1","executionId":"exec-1","status":"accepted"}}
{"v":1,"seq":5,"ts":1757404800400,"sessionId":"s-1","runId":"r-1","type":"execution.updated","data":{"executionId":"exec-1","status":"succeeded"}}
{"v":1,"seq":6,"ts":1757404800500,"sessionId":"s-1","runId":"r-1","type":"approval.requested","data":{"approvalId":"apr-1","capabilityId":"aps.schedule.publish","paramsDigest":"sha256:1c4d","impactSummary":"发布 2026-10 排产方案,影响 12 个订单","revision":37,"expiresAt":1757408400000}}
{"v":1,"seq":7,"ts":1757404800600,"sessionId":"s-1","runId":"r-1","type":"approval.resolved","data":{"approvalId":"apr-1","decision":"approved"}}
{"v":1,"seq":8,"ts":1757404800700,"sessionId":"s-1","runId":"r-1","type":"run.state","data":{"state":"idle"}}
{"v":1,"seq":9,"ts":1757404800800,"sessionId":"s-1","type":"session.updated","data":{"title":"十月排产核对","status":"active"}}
{"v":1,"seq":10,"ts":1757404800900,"sessionId":"s-1","type":"error","data":{"code":"provider.timeout","message":"APS 求解超时","retryable":true,"errorKind":"timeout"}}
{"v":1,"seq":11,"ts":1757404801000,"sessionId":"s-1","type":"heartbeat","data":{}}

View File

@ -0,0 +1,29 @@
{
"session": {
"schema": "session.schema.json",
"value": { "sessionId": "s-1", "projectId": "p-1", "scope": "project", "status": "active", "title": "十月排产核对", "createdAt": 1757404800000 }
},
"capability": {
"schema": "capability.schema.json",
"value": {
"id": "aps.schedule.preview",
"providerId": "aps",
"title": "试排一版",
"purpose": "按当前项目数据试排一版方案,只出结果不发布",
"effect": "read",
"synonyms": ["试排", "跑一版", "预排"]
}
},
"receipt": {
"schema": "receipt.schema.json",
"value": { "executionId": "exec-1", "status": "pending-confirmation", "approvalId": "apr-1" }
},
"approvalDecision": {
"schema": "approval.schema.json",
"value": { "decision": "approve", "revision": 37, "idempotencyKey": "idem-2f8a1c9d", "note": "按会议结论发布" }
},
"error": {
"schema": "error.schema.json",
"value": { "error": { "code": "auth.expired", "message": "登录态已过期", "retryable": false } }
}
}

View File

@ -2,22 +2,10 @@
* 客户端与服务端共享的契约(架构设计第 2 节)。
*
* 只放类型与模式定义,不引入运行时依赖:这一层被所有包依赖,任何依赖都会变成全局耦合。
* 引擎差异不在这里体现——引擎事件经适配层转成下面的平台事件,界面对引擎无感。
* JSON Schema 在 contracts/schemas/ 是规范本体,本包里的 TS 是镜像;两者由
* scripts/check-contracts.test.mjs 卡漂移。
*/
export * from './events.js';
export * from './protocol.js';
/** 能力标识,形如 `aps.schedule.preview`。命名与描述规范见 docs/spec/capability-description.md */
export type CapabilityId = string;
/** 执行回执:所有能力调用的统一返回(架构设计 4.2) */
export interface ExecutionReceipt {
executionId: string;
status: 'accepted' | 'succeeded' | 'failed' | 'pending-confirmation';
result?: unknown;
errorKind?: string;
}
/** 平台事件:唯一面向客户端的事件形态(实施设计 1.7 定义事件模型) */
export type PlatformEvent =
| { type: 'message.delta'; sessionId: string; text: string }
| { type: 'tool.call'; sessionId: string; capabilityId: CapabilityId }
| { type: 'receipt'; sessionId: string; receipt: ExecutionReceipt };
export const CONTRACTS_PACKAGE = '@aps/contracts';

142
contracts/protocol.ts Normal file
View File

@ -0,0 +1,142 @@
/**
* 协议 v1 的 HTTP 表面(JSON Schema 为准,这里是 TS 镜像)。
*
* 服务端与客户端之间只认这一套接口;引擎协议(Pi 的 JSONL、Codex 的 app-server)不越过适配层。
* 端点与规则见 docs/spec/protocol.md。
*/
/** 路径前缀:协议改版就换前缀,不原地改语义 */
export const PROTOCOL_PREFIX = '/v1' as const;
export const ENDPOINTS = {
health: '/v1/health',
me: '/v1/me',
projects: '/v1/projects',
sessions: '/v1/sessions',
session: (sessionId: string) => `/v1/sessions/${sessionId}`,
/** SSE:回放 + 实时事件流 */
sessionEvents: (sessionId: string) => `/v1/sessions/${sessionId}/events`,
sessionMessages: (sessionId: string) => `/v1/sessions/${sessionId}/messages`,
sessionCancel: (sessionId: string) => `/v1/sessions/${sessionId}/cancel`,
capabilities: '/v1/capabilities',
execution: (executionId: string) => `/v1/executions/${executionId}`,
approval: (approvalId: string) => `/v1/approvals/${approvalId}`,
} as const;
/** 能力标识,形如 `aps.schedule.preview` */
export type CapabilityId = string;
export interface HealthResponse {
status: 'ok';
version: string;
protocolVersion: number;
}
export interface MeResponse {
userId: string;
tenantId: string;
displayName: string;
/** 桌面端与浏览器共用同一身份模型,登录方式不同(见协议文档第 2 节) */
clientKind: 'desktop' | 'browser';
}
/** 能力条目:按身份过滤后返回;描述七要素见 docs/spec/capability-description.md */
export interface CapabilitySummary {
id: CapabilityId;
providerId: string;
title: string;
purpose: string;
effect: 'read' | 'draft' | 'write';
inputSchema?: Record<string, unknown>;
synonyms?: string[];
}
export interface CapabilitiesResponse {
capabilities: CapabilitySummary[];
}
/** 会话概要(ADR-0003:个人会话只读,写操作必须在项目会话内) */
export interface SessionSummary {
sessionId: string;
projectId: string | null;
scope: 'personal' | 'project';
status: 'active' | 'archived';
title: string;
createdAt: number;
}
export interface CreateSessionRequest {
projectId?: string;
scope: 'personal' | 'project';
title?: string;
}
export interface CreateSessionResponse {
session: SessionSummary;
}
export interface SessionListResponse {
sessions: SessionSummary[];
}
export interface SessionDetailResponse {
session: SessionSummary;
/** 最近一段消息;完整历史走事件流回放 */
messages: ChatMessage[];
}
export interface ChatMessage {
messageId: string;
role: 'user' | 'assistant' | 'system';
ts: number;
text: string;
}
export interface SendMessageRequest {
text: string;
/** 同一写请求重试不重复生效 */
idempotencyKey: string;
}
export interface SendMessageResponse {
accepted: true;
messageId: string;
runId: string;
}
export interface CancelResponse {
state: 'cancelling' | 'idle';
}
/** 能力调用的统一回执(架构设计 4.2) */
export interface ExecutionReceipt {
executionId: string;
status: 'accepted' | 'pending-confirmation' | 'succeeded' | 'failed';
result?: unknown;
errorKind?: string;
/** status = pending-confirmation 时给出待确认动作标识 */
approvalId?: string;
}
export interface ApprovalDecisionRequest {
decision: 'approve' | 'reject';
/** 目标数据版本;版本变了确认就失效 */
revision: number;
idempotencyKey: string;
note?: string;
}
export interface ApprovalDecisionResponse {
approvalId: string;
decision: 'approved' | 'rejected' | 'expired';
receipt?: ExecutionReceipt;
}
export interface ErrorEnvelope {
error: {
code: string;
message: string;
retryable: boolean;
detail?: unknown;
};
}

View File

@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://aps.local/contracts/approval.schema.json",
"title": "ApprovalDecisionRequest",
"description": "客户端对确认卡的响应(架构设计 4.2:确认卡防的是误解意图,不是防越权)。",
"type": "object",
"required": ["decision", "revision", "idempotencyKey"],
"properties": {
"decision": { "type": "string", "enum": ["approve", "reject"] },
"revision": { "type": "integer", "minimum": 0 },
"idempotencyKey": { "type": "string", "minLength": 8 },
"note": { "type": "string" }
},
"additionalProperties": false
}

View File

@ -0,0 +1,18 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://aps.local/contracts/capability.schema.json",
"title": "CapabilitySummary",
"description": "按身份过滤后交给客户端与引擎的能力条目。描述七要素见 docs/spec/capability-description.md。",
"type": "object",
"required": ["id", "providerId", "title", "purpose", "effect"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9]+(\\.[a-z0-9-]+)+$" },
"providerId": { "type": "string" },
"title": { "type": "string" },
"purpose": { "type": "string", "description": "一句话用途,40 字以内" },
"effect": { "type": "string", "enum": ["read", "draft", "write"] },
"inputSchema": { "type": "object" },
"synonyms": { "type": "array", "items": { "type": "string" }, "description": "业务黑话与同义词" }
},
"additionalProperties": false
}

View File

@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://aps.local/contracts/envelope.schema.json",
"title": "EventEnvelope",
"description": "服务端推给客户端的事件信封(协议 v1)。界面只依赖这一层,不感知引擎差异。",
"$defs": {
"status": {
"type": "string",
"enum": ["accepted", "pending-confirmation", "succeeded", "failed"]
},
"envelope": {
"type": "object",
"required": ["v", "seq", "ts", "sessionId", "type", "data"],
"properties": {
"v": { "const": 1, "description": "协议版本;改协议就加版本,不改这一版" },
"seq": { "type": "integer", "minimum": 1, "description": "会话内单调递增;断点续传与去重靠它" },
"ts": { "type": "integer", "description": "发生时间,整数毫秒(ADR-0007)" },
"sessionId": { "type": "string", "minLength": 1 },
"runId": { "type": "string", "description": "一次运行(回合)的标识;同会话可先后多个 run" },
"type": { "type": "string" },
"data": { "type": "object" }
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,22 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://aps.local/contracts/error.schema.json",
"title": "ErrorEnvelope",
"description": "HTTP 错误统一形状;SSE 里的错误见 events.schema.json 的 error 事件。",
"type": "object",
"required": ["error"],
"properties": {
"error": {
"type": "object",
"required": ["code", "message", "retryable"],
"properties": {
"code": { "type": "string" },
"message": { "type": "string" },
"retryable": { "type": "boolean" },
"detail": {}
},
"additionalProperties": false
}
},
"additionalProperties": false
}

View File

@ -0,0 +1,191 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://aps.local/contracts/events.schema.json",
"title": "PlatformEvent",
"description": "协议 v1 的全部事件类型。新增事件类型必须同时改这里、contracts/events.ts 与 docs/spec/protocol.md。",
"$ref": "#/$defs/event",
"$defs": {
"envelope": {
"allOf": [{ "$ref": "envelope.schema.json#/$defs/envelope" }]
},
"messageDelta": {
"allOf": [{ "$ref": "#/$defs/envelope" }],
"properties": {
"type": { "const": "message.delta" },
"data": {
"type": "object",
"required": ["messageId", "text"],
"properties": {
"messageId": { "type": "string" },
"text": { "type": "string" }
},
"additionalProperties": false
}
}
},
"messageCompleted": {
"allOf": [{ "$ref": "#/$defs/envelope" }],
"properties": {
"type": { "const": "message.completed" },
"data": {
"type": "object",
"required": ["messageId"],
"properties": {
"messageId": { "type": "string" },
"finishReason": { "type": "string", "enum": ["stop", "cancelled", "error", "length"] }
},
"additionalProperties": false
}
}
},
"toolCall": {
"allOf": [{ "$ref": "#/$defs/envelope" }],
"properties": {
"type": { "const": "tool.call" },
"data": {
"type": "object",
"required": ["callId", "capabilityId", "argsDigest"],
"properties": {
"callId": { "type": "string" },
"capabilityId": { "type": "string" },
"argsDigest": { "type": "string", "description": "参数摘要,不是参数明文" }
},
"additionalProperties": false
}
}
},
"toolResult": {
"allOf": [{ "$ref": "#/$defs/envelope" }],
"properties": {
"type": { "const": "tool.result" },
"data": {
"type": "object",
"required": ["callId", "executionId", "status"],
"properties": {
"callId": { "type": "string" },
"executionId": { "type": "string" },
"status": { "$ref": "envelope.schema.json#/$defs/status" }
},
"additionalProperties": false
}
}
},
"executionUpdated": {
"allOf": [{ "$ref": "#/$defs/envelope" }],
"properties": {
"type": { "const": "execution.updated" },
"data": {
"type": "object",
"required": ["executionId", "status"],
"properties": {
"executionId": { "type": "string" },
"status": { "$ref": "envelope.schema.json#/$defs/status" },
"errorKind": { "type": "string" }
},
"additionalProperties": false
}
}
},
"approvalRequested": {
"allOf": [{ "$ref": "#/$defs/envelope" }],
"properties": {
"type": { "const": "approval.requested" },
"data": {
"type": "object",
"required": ["approvalId", "capabilityId", "paramsDigest", "impactSummary", "revision", "expiresAt"],
"properties": {
"approvalId": { "type": "string" },
"capabilityId": { "type": "string" },
"paramsDigest": { "type": "string" },
"impactSummary": { "type": "string", "description": "确认卡正文:影响范围与关键参数摘要" },
"revision": { "type": "integer", "minimum": 0, "description": "目标数据版本;版本变了确认就失效" },
"expiresAt": { "type": "integer" }
},
"additionalProperties": false
}
}
},
"approvalResolved": {
"allOf": [{ "$ref": "#/$defs/envelope" }],
"properties": {
"type": { "const": "approval.resolved" },
"data": {
"type": "object",
"required": ["approvalId", "decision"],
"properties": {
"approvalId": { "type": "string" },
"decision": { "type": "string", "enum": ["approved", "rejected", "expired"] }
},
"additionalProperties": false
}
}
},
"runState": {
"allOf": [{ "$ref": "#/$defs/envelope" }],
"properties": {
"type": { "const": "run.state" },
"data": {
"type": "object",
"required": ["state"],
"properties": {
"state": { "type": "string", "enum": ["idle", "running", "cancelling", "cancelled"] }
},
"additionalProperties": false
}
}
},
"sessionUpdated": {
"allOf": [{ "$ref": "#/$defs/envelope" }],
"properties": {
"type": { "const": "session.updated" },
"data": {
"type": "object",
"properties": {
"title": { "type": "string" },
"status": { "type": "string", "enum": ["active", "archived"] }
},
"additionalProperties": false
}
}
},
"error": {
"allOf": [{ "$ref": "#/$defs/envelope" }],
"properties": {
"type": { "const": "error" },
"data": {
"type": "object",
"required": ["code", "message", "retryable"],
"properties": {
"code": { "type": "string" },
"message": { "type": "string" },
"retryable": { "type": "boolean" },
"errorKind": { "type": "string" }
},
"additionalProperties": false
}
}
},
"heartbeat": {
"allOf": [{ "$ref": "#/$defs/envelope" }],
"properties": {
"type": { "const": "heartbeat" },
"data": { "type": "object", "additionalProperties": false }
}
},
"event": {
"oneOf": [
{ "$ref": "#/$defs/messageDelta" },
{ "$ref": "#/$defs/messageCompleted" },
{ "$ref": "#/$defs/toolCall" },
{ "$ref": "#/$defs/toolResult" },
{ "$ref": "#/$defs/executionUpdated" },
{ "$ref": "#/$defs/approvalRequested" },
{ "$ref": "#/$defs/approvalResolved" },
{ "$ref": "#/$defs/runState" },
{ "$ref": "#/$defs/sessionUpdated" },
{ "$ref": "#/$defs/error" },
{ "$ref": "#/$defs/heartbeat" }
]
}
}
}

View File

@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://aps.local/contracts/receipt.schema.json",
"title": "ExecutionReceipt",
"description": "能力调用的统一回执(架构设计 4.2)。",
"type": "object",
"required": ["executionId", "status"],
"properties": {
"executionId": { "type": "string" },
"status": { "$ref": "envelope.schema.json#/$defs/status" },
"result": {},
"errorKind": { "type": "string" },
"approvalId": { "type": "string", "description": "status = pending-confirmation 时给出待确认动作标识" }
},
"additionalProperties": false
}

View File

@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://aps.local/contracts/session.schema.json",
"title": "SessionSummary",
"description": "会话概要(ADR-0003:个人会话只读,写操作必须在项目会话内)。",
"type": "object",
"required": ["sessionId", "scope", "status", "title", "createdAt"],
"properties": {
"sessionId": { "type": "string" },
"projectId": { "type": ["string", "null"] },
"scope": { "type": "string", "enum": ["personal", "project"] },
"status": { "type": "string", "enum": ["active", "archived"] },
"title": { "type": "string" },
"createdAt": { "type": "integer" }
},
"additionalProperties": false
}

View File

@ -5,5 +5,5 @@
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"include": ["index.ts"]
"include": ["*.ts"]
}

107
docs/spec/protocol.md Normal file
View File

@ -0,0 +1,107 @@
# 客户端与服务端协议(v1)
> 契约本体在 `contracts/schemas/`(JSON Schema),TS 镜像在 `contracts/`,漂移由 `scripts/check-contracts.test.mjs` 卡。本文是给人看的同一份东西。
## 1 范围与所有权
服务端与客户端之间只认这一套接口。引擎协议(Pi 的 JSONL、Codex 的 app-server)只活在 `server/agent` 内部,客户端与 Core 都不感知——协议的所有权比协议的兼容性重要(架构设计 5.2)。
界面只依赖本文的事件模型:换引擎、换模型,事件不变。
## 2 版本与兼容规则
| 规则 | 说明 |
| --- | --- |
| 路径版本 | 前缀 `{PROTOCOL_PREFIX}` = `/v1`;改语义就换前缀,不原地改 |
| 事件版本 | 每个事件带 `v`,当前 `1`;字段只加不改不删 |
| 删除字段 | 先废弃(保留但不再填充)→ 下一个大版本再删 |
| 未知事件 | 客户端必须忽略不认识的 `type`,不能崩 |
| 未知字段 | 客户端必须忽略,不能因多字段报错 |
## 3 认证与身份
| 客户端 | 方式 | 说明 |
| --- | --- | --- |
| 浏览器 | `Cookie: ap_session=<httpOnly>` | 沿用现有 JMS 登录;服务端不把业务系统 token 下发给客户端 |
| 桌面端 | `Authorization: Bearer <desktop-token>` | 授权码 + 设备绑定换来的短期令牌(沿用现有授权方式) |
两者都由 `server/entry` 解析成同一个 Subject(用户 + 租户),再交给应用服务与 Core。首次调用的返回值就是 `MeResponse`。
## 4 端点
| 方法 | 路径 | 请求 | 响应 | 说明 |
| --- | --- | --- | --- | --- |
| GET | `/v1/health` | — | `HealthResponse` | 健康检查 |
| GET | `/v1/me` | — | `MeResponse` | 当前身份与客户端形态 |
| GET | `/v1/projects` | — | `ProjectSummary[]` | 可见项目 |
| GET | `/v1/sessions` | `?projectId=` | `SessionListResponse` | 会话列表 |
| POST | `/v1/sessions` | `CreateSessionRequest` | `CreateSessionResponse` | 建会话;写操作必须建在项目会话(ADR-0003) |
| GET | `/v1/sessions/{id}` | `?limit=` | `SessionDetailResponse` | 会话详情与最近消息 |
| GET | `/v1/sessions/{id}/events` | `Last-Event-ID` 或 `?since=` | SSE 事件流 | 回放 + 实时,见第 5 节 |
| POST | `/v1/sessions/{id}/messages` | `SendMessageRequest` | `SendMessageResponse` | 发消息;返回受理,结果走事件流 |
| POST | `/v1/sessions/{id}/cancel` | — | `CancelResponse` | 取消当前运行 |
| GET | `/v1/capabilities` | `?sessionId=` | `CapabilitiesResponse` | 按身份过滤的能力清单 |
| GET | `/v1/executions/{executionId}` | — | `ExecutionReceipt` | 执行状态与回执 |
| POST | `/v1/approvals/{approvalId}` | `ApprovalDecisionRequest` | `ApprovalDecisionResponse` | 确认卡响应 |
## 5 事件流(SSE)
| 规则 | 说明 |
| --- | --- |
| 传输 | `text/event-stream`;`event:` 填事件 `type`,`data:` 填整条事件 JSON |
| 序号 | `seq` 在会话内单调递增,从 1 开始;客户端按它去重与排序 |
| 断点续传 | 客户端带 `Last-Event-ID`(或 `?since=`)重连,服务端从该序号之后回放 |
| 回放 | 回放与实时用同一格式,客户端不需要两套解析 |
| 心跳 | 无事件时按固定间隔发 `heartbeat`,用于探活与代理保活 |
| 顺序 | 同一会话的事件严格有序;不同会话之间不保证 |
| 敏感 | 事件里只有参数摘要(`argsDigest`、`paramsDigest`),没有凭据与参数明文 |
## 6 事件清单
| 事件 | 含义 | `data` 关键字段 |
| --- | --- | --- |
| `message.delta` | 助手增量文本 | `messageId`、`text` |
| `message.completed` | 一条助手消息结束 | `messageId`、`finishReason` |
| `tool.call` | 引擎请求调用能力 | `callId`、`capabilityId`、`argsDigest` |
| `tool.result` | 能力调用的回执 | `callId`、`executionId`、`status` |
| `execution.updated` | 执行状态变化 | `executionId`、`status`、`errorKind` |
| `approval.requested` | 需要用户确认 | `approvalId`、`capabilityId`、`paramsDigest`、`impactSummary`、`revision`、`expiresAt` |
| `approval.resolved` | 确认结果 | `approvalId`、`decision` |
| `run.state` | 当前运行状态 | `state`(`idle`/`running`/`cancelling`/`cancelled`) |
| `session.updated` | 会话标题或状态变化 | `title`、`status` |
| `error` | 错误 | `code`、`message`、`retryable` |
| `heartbeat` | 保活 | 空对象 |
`status` 取值统一为 `accepted` / `pending-confirmation` / `succeeded` / `failed`。
## 7 审批时序
1. 引擎请求写能力 → Core 判定需确认 → 落一条待确认动作(ADR-0003、架构设计 4.2)
2. 服务端推 `approval.requested`,客户端渲染确认卡(影响范围、参数摘要、目标版本、有效期)
3. 用户点批准 → `POST /v1/approvals/{id}`,带 `revision` 与 `idempotencyKey`
4. 服务端校验版本与有效期 → 推 `approval.resolved` → 执行 → 推 `execution.updated`
5. 版本过期或已执行 → 返回 `expired`,客户端提示重新确认;确认人是会话发起人本人
## 8 错误模型
| 位置 | 形状 | 说明 |
| --- | --- | --- |
| HTTP | `ErrorEnvelope` | 请求本身失败(鉴权、参数、找不到) |
| SSE | `error` 事件 | 运行中失败;`retryable` 表示可重试,客户端据此给"重试"按钮 |
## 9 幂等与取消
| 场景 | 规则 |
| --- | --- |
| 发消息 | 带 `idempotencyKey`;同一 key 重试不产生第二个 run |
| 审批 | 带 `idempotencyKey`;同一 key 只生效一次 |
| 取消 | 幂等:重复取消不报错;已结束的运行返回 `idle` |
| 结果未知 | 执行超时返回 `failed` 且 `errorKind = unknown`,由客户端提示查执行记录,不自动重试写操作 |
## 10 变更规则
改协议必须同时改三处,否则 `pnpm test` 会失败:
1. `contracts/schemas/*.json`(规范本体)
2. `contracts/events.ts` / `contracts/protocol.ts`(TS 镜像)
3. 本文档(第 4、6 节的表)

View File

@ -13,6 +13,7 @@
"test": "node --test \"scripts/**/*.test.mjs\""
},
"devDependencies": {
"ajv": "^8.20.0",
"typescript": "^7.0.2"
}
}

View File

@ -8,6 +8,9 @@ importers:
.:
devDependencies:
ajv:
specifier: ^8.20.0
version: 8.20.0
typescript:
specifier: ^7.0.2
version: 7.0.2
@ -168,6 +171,22 @@ packages:
cpu: [x64]
os: [win32]
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-uri@3.1.8:
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
typescript@7.0.2:
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
engines: {node: '>=16.20.0'}
@ -235,6 +254,21 @@ snapshots:
'@typescript/typescript-win32-x64@7.0.2':
optional: true
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.8
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
fast-deep-equal@3.1.3: {}
fast-uri@3.1.8: {}
json-schema-traverse@1.0.0: {}
require-from-string@2.0.2: {}
typescript@7.0.2:
optionalDependencies:
'@typescript/typescript-aix-ppc64': 7.0.2

View File

@ -0,0 +1,87 @@
/**
* 契约漂移检查(任务 A1.7)。
*
* 三件事:
* 1. 事件与 HTTP 样例必须符合 contracts/schemas 下的 JSON Schema
* 2. 事件类型在 schema、TS 镜像、协议文档三处必须完全一致
* 3. 每种事件类型都要有样例,避免新增类型忘了配样例
*/
import assert from 'node:assert/strict';
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import Ajv2020 from 'ajv/dist/2020.js';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const SCHEMA_DIR = join(ROOT, 'contracts', 'schemas');
const EVENTS_SCHEMA_ID = 'https://aps.local/contracts/events.schema.json';
const readText = (rel) => readFile(join(ROOT, rel), 'utf8');
async function buildAjv() {
const ajv = new Ajv2020({ allErrors: true, strict: false });
for (const name of await readdir(SCHEMA_DIR)) {
if (!name.endsWith('.json')) continue;
ajv.addSchema(JSON.parse(await readFile(join(SCHEMA_DIR, name), 'utf8')));
}
return ajv;
}
const formatErrors = (errors) => (errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`).join('; ');
test('事件样例符合 events.schema.json', async () => {
const ajv = await buildAjv();
const validate = ajv.getSchema(EVENTS_SCHEMA_ID);
assert.ok(validate, '未注册 events.schema.json');
const lines = (await readText('contracts/fixtures/events.jsonl')).split(/\r?\n/).filter((l) => l.trim());
assert.ok(lines.length >= 11, `样例太少:${lines.length}`);
for (const line of lines) {
const event = JSON.parse(line);
assert.ok(validate(event), `${event.type} 样例不符合 schema:${formatErrors(validate.errors)}`);
}
});
test('HTTP 样例符合各自 schema', async () => {
const ajv = await buildAjv();
const cases = JSON.parse(await readText('contracts/fixtures/http.json'));
for (const [name, testCase] of Object.entries(cases)) {
const schemaId = `https://aps.local/contracts/${testCase.schema}`;
const validate = ajv.getSchema(schemaId);
assert.ok(validate, `${name}:未注册 ${testCase.schema}`);
assert.ok(validate(testCase.value), `${name} 样例不符合 ${testCase.schema}:${formatErrors(validate.errors)}`);
}
});
test('事件类型在 schema、TS 与协议文档三处一致', async () => {
const schemaTypes = new Set(
[...(await readText('contracts/schemas/events.schema.json')).matchAll(/"const":\s*"([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)"/g)].map((m) => m[1]),
);
const tsTypes = new Set([...(await readText('contracts/events.ts')).matchAll(/EventEnvelope<'([^']+)'/g)].map((m) => m[1]));
const docTypes = new Set([...(await readText('docs/spec/protocol.md')).matchAll(/^\| `([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)` \|/gm)].map((m) => m[1]));
assert.ok(schemaTypes.size >= 11, `schema 里事件类型太少:${schemaTypes.size}`);
const diff = (a, b, label) => [...a].filter((t) => !b.has(t)).map((t) => `${label}:${t}`);
const problems = [
...diff(schemaTypes, tsTypes, '只在 schema'),
...diff(tsTypes, schemaTypes, '只在 TS'),
...diff(schemaTypes, docTypes, '只在 schema(文档缺)'),
...diff(docTypes, schemaTypes, '只在文档'),
];
assert.deepEqual(problems, [], `事件类型不一致:\n${problems.join('\n')}`);
});
test('每种事件类型都有样例', async () => {
const fixtureTypes = new Set(
(await readText('contracts/fixtures/events.jsonl'))
.split(/\r?\n/)
.filter((l) => l.trim())
.map((l) => JSON.parse(l).type),
);
const schemaTypes = new Set(
[...(await readText('contracts/schemas/events.schema.json')).matchAll(/"const":\s*"([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)"/g)].map((m) => m[1]),
);
const missing = [...schemaTypes].filter((t) => !fixtureTypes.has(t));
assert.deepEqual(missing, [], `以下事件类型缺样例:${missing.join('、')}`);
});