源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s

This commit is contained in:
agent-radar
2026-09-01 20:17:27 +08:00
commit 2ed3415fb7
8018 changed files with 1936476 additions and 0 deletions
@@ -0,0 +1,162 @@
# Agent Provider Status: 模型为真相源,读/检测反转
日期:2026-06-28
范围:`services/tuttid` 的 agentstatus 服务 + daemon API + tuttid 客户端 + desktop 状态 service
状态:待评审
## 问题
桌面端安装进行中每 1 秒轮询进度(active-action),但进度只能从 `GetAgentProviderStatuses`(= `agentstatus.Service.List`)的 `activeAction` 字段拿到,而 `List` **每次调用都现场全量探测**:跑 `--version`、发 HTTP 探网络(registry/api/proxy)、查 adapter。于是高频轮询被迫每秒重探网络,网络不稳时反复闪红、整条轨道反复重检测。
**根因(从头看)**:`List` 把"状态"建模成了**按需计算的纯函数**——"现在算一遍当前真相"。所以**读 = 检测**,这是反的。
## 不同信号的本质温度不同
| 信号 | 何时变 | 本质 |
| ---------------------- | --------------------- | --------------------------- |
| 已装 CLI/adapter、版本 | 仅在我们跑安装/卸载时 | 事件驱动(daemon 自己知道) |
| 登录态 | 仅登录/登出 | 事件驱动 |
| 网络可达性 | 偶发、外部 | 按需探测、可缓存 |
| 安装进度 activeAction | 安装中持续 | 流 |
把四种温度不同的东西塞进一个"同步现算"的调用——是丑的来源。
## 设计原则:反转读与检测
**状态是 daemon 维护的一个模型(single source of truth),客户端观察它;读是廉价默认,检测是显式命令。**
- **读(`GetStatus`)**:读模型 + 叠加实时 activeAction,**不探测任何东西**。高频、可轮询。
- **检测(`Detect`)**:探网络 + 复核已装/登录态,**更新模型**。低频,仅在打开 / 「重新检测」/ 动作收尾。
- **网络是模型里一个被探测的字段**,只在 Detect 时刷新;读直接返回上次探到的稳定值。
- **进度(activeAction)本就在模型里实时流动**。
本设计是这个正确架构的**同步版**(轮询廉价读);其反应式版(`Watch` 订阅、彻底去轮询)见「未来方向」。
## 关键约束(已核查代码)
1. **`agentstatus.Service` 是值类型**,被按值拷贝、甚至在 `provider_availability.go:149` new 出空实例。→ 状态模型必须放**包级全局**(与 `active_action.go` 同模式),不能挂 `Service` 字段。
2. daemon 长生命周期(`wiring.go:215` 构造一次),包级模型跨请求存活。
3. `GetAgentProviderStatuses` / `Probe` 是 desktop↔daemon **本地 API**,无外部消费者。当前 `List` 仅两个调用方:前端 `desktopAgentProviderStatusService` + daemon 内部 `provider_availability.go:151`。→ 翻转端点语义安全、可控。
## 各单元设计
### 1. 状态模型 — `services/tuttid/service/agentstatus/status_store.go`(新)
包级全局,与 `active_action.go` 同模式:
```go
var statusStore = struct {
sync.Mutex
byProvider map[string]storedStatus
}{byProvider: map[string]storedStatus{}}
type storedStatus struct {
status ProviderStatus // 含 Network(上次 Detect 探到的稳定值)
capturedAt time.Time
}
// putDetectedStatus 由 Detect 在一次探测后写入。
func putDetectedStatus(provider string, status ProviderStatus, capturedAt time.Time)
// readStatuses 返回所请求 provider 的模型状态(已 Detect 过的那些),
// 每个叠加实时 active-action,外加最新 capturedAt。无探测。未 Detect 过的 provider 省略。
func readStatuses(providers []string) (statuses []ProviderStatus, capturedAt time.Time)
func resetStatusStoreForTests()
```
`readStatuses` 对每个返回项 `status.ActiveAction = activeActionForProvider(p)`(实时进度覆盖模型里的旧值)。
### 2. `Service.Detect`(命令)— `service.go`
把今天 `List`(`service.go:238`)的函数体**原样保留**(现场探网络 + `statusForSpec` 全检测),仅在末尾**写入模型**:
```go
func (s Service) Detect(ctx context.Context, input ListInput) (Snapshot, error) {
// ……今天 List 的探测逻辑原样……(probeRegistry/probeProxy/probeProviderAPI + statusForSpec)
now := s.now()
for i := range statuses {
statuses[i].ActiveAction = activeActionForProvider(statuses[i].Provider)
putDetectedStatus(statuses[i].Provider, statuses[i], now) // 新增:写模型
}
return Snapshot{CapturedAt: now, Providers: statuses}, nil
}
```
(即:现有 `List` 重命名为 `Detect` + 写模型一行。语义=今天的 List:现场探测、返回新鲜。)
### 3. `Service.GetStatus`(读)— `service.go`(新)
```go
// GetStatus 返回模型里各 provider 的已检测状态 + 实时 active-action,不做任何探测。
// 冷模型(尚未 Detect)→ 空 Providers。这是廉价、可高频轮询的默认读。
func (s Service) GetStatus(input ListInput) (Snapshot, error) {
statuses, capturedAt := readStatuses(input.Providers)
if len(statuses) == 0 {
return Snapshot{CapturedAt: s.now(), Providers: nil}, nil
}
return Snapshot{CapturedAt: capturedAt, Providers: statuses}, nil
}
```
无 `ctx`(无探测);保留 `input ListInput` 复用 `Providers` 过滤。
### 4. daemon API:端点诚实翻转 — `daemon_agent_statuses.go` + openapi
- **`GetAgentProviderStatuses`(现存端点)→ 改为调 `GetStatus`**(廉价读)。名副其实。
- **新增 `DetectAgentProviders`(命令端点,如 `POST /agent-providers/detect`,query/body `providers`)→ 调 `Detect`**。复用现有响应 schema `AgentProviderStatusListResponse`。
- 重新生成 `server.gen.go` / `types.gen.go`(Go)+ `packages/clients/tuttid-ts` 的 `types.gen.ts`。
- **重指调用方**:daemon 内部 `provider_availability.go:151` 由 `List` 改调 `Detect`(它要新鲜可用性)。
### 5. tuttid 客户端 — `packages/clients/tuttid-ts`
- 生成 `detectAgentProviders(request)`(命令,同响应类型)。
- `getAgentProviderStatuses` 保留(现在打到廉价读端点)。
- `createDesktopTuttidClient.ts` + `tuttidClientTypes.ts` 各加一条 `detectAgentProviders`。
### 6. desktop 状态 service — `desktopAgentProviderStatusService.ts`
把"取数"分成两条语义清晰的路径:
- **detect 路径**(`requestStatuses` 内部):改调 `tuttidClient.detectAgentProviders(...)`。用于:初次 `ensureLoaded`、`refresh`、「重新检测」、动作收尾(install/login 后那次 refresh)。可置 `isLoading`(这是真正的检测)。
- **read 路径**(新 `private async readStatus(providers)`):调 `tuttidClient.getAgentProviderStatuses(...)`,经现有 reconcile 应用结果,但**不置 `isLoading`**(后台轮询)。
- `runPendingActionStatusPoll`(约 `:633`):`await this.refresh(...)` → `await this.readStatus([provider])`。
- 因为模型已持有上次探到的 network,read 返回的 status 自带稳定 network——**前端无需任何字段保留/合并特例**。
## 数据流
```
打开 / 「重新检测」/ 动作收尾 ─► Detect ─► 现场探测 + 写模型 ─► 前端拿到完整新鲜状态
安装中每秒轮询进度 ─► GetStatus ─► 读模型(稳定 network) + 实时 activeAction,不探测、不置 isLoading
provider_availability ─► Detect ─► 新鲜可用性(行为同今天)
```
## 错误处理与边界
- **冷模型**:read 在任何 Detect 之前被调 → 返回空 Providers。前端总在打开时先 Detect,故轮询时模型必热;万一空,前端 reconcile 保留已持有快照。
- read 永不触发 Detect(职责单一)。
- read IPC 失败:沿用现有 refresh 错误路径(记录、保留上次快照、不弹通知)。
- 并发:模型读写用 mutex(同 `active_action.go`)。Detect 写 + read 读并发安全。
## 测试矩阵
| 单元 | 类型 | 关键用例 |
| ----------------------- | --------- | ----------------------------------------------------------------------------------------------------------- |
| `status_store_test.go` | Go 纯 | put 后 read 命中;read 叠加实时 active-action;冷模型返回空;多 provider 过滤 |
| `Service.Detect` | Go | 探测后**写了模型**(Detect 前 GetStatus 空,Detect 后命中);返回新鲜(行为同旧 List) |
| `Service.GetStatus` | Go(spy) | **不调** `probeRegistry/probeProxy/probeProviderAPI`(probe spy 断言零调用);返回模型 + 实时 active-action |
| daemon API | Go | `GetAgentProviderStatuses` → GetStatus;`DetectAgentProviders` → Detect;序列化正确 |
| `provider_availability` | Go | 改调 Detect 后行为不变 |
| desktop service | node:test | poll 走 read 端点、**不置 isLoading**;detect 路径走 detect 端点;安装收尾仍 detect |
## 未来方向(不在本 spec)
- **② 反应式订阅**:在状态模型上加 `Watch`,daemon 把模型变化推给前端,前端订阅,**轮询彻底消失**。与前端 store+subscription 重构同一哲学。本设计的模型即其天然基座——届时 `GetStatus` 退化为"订阅时的初始快照"。
- **登录轮询**(`loginStatusPoll`):现仍走 detect(需复核 auth)。模型铺好后可改为"动作完成事件更新模型 + read",或等 ② 订阅统一。本 spec 不动它。
## 验收标准
- 安装进行中:网络阶段**不再每秒重探/闪红**(read 返回模型里上次 Detect 的稳定 network)。
- 安装进行中:「重新检测」按钮稳定禁用(#454 已改 + read 不置 isLoading 消除另一来源)。
- `GetAgentProviderStatuses` 端点**不再做任何探测**(spy 断言);探测只发生在 `DetectAgentProviders` / 动作收尾。
- `provider_availability` 等调用方行为不变(已重指到 Detect)。
@@ -0,0 +1,404 @@
# Agent Session Fork Design
Status: superseded by
[2026-07-27 Agent Session Fork Design](./2026-07-27-agent-session-fork-design.md)
Date: 2026-07-01
## Summary
Tutti should implement agent session fork as a first-class provider-backed
operation. The source of truth for a Codex fork is the Codex app-server
`thread/fork` RPC. Tutti then projects the forked provider thread into its own
durable Agent GUI activity store so the child conversation can be listed,
opened, continued, and traced back to its parent.
The standard implementation must not create a fork by only copying Agent GUI
messages. Message copying alone creates a UI-level clone, but it does not create
a new provider thread and does not guarantee that subsequent model turns use the
forked Codex context.
## Goals
- Support forking a Codex-backed Tutti agent session into a new child session.
- Preserve parent session state without mutation.
- Give the child session a new Tutti `agentSessionId`.
- Give the child session a new Codex `providerSessionId`, returned by
`thread/fork`.
- Preserve the parent history up to the fork boundary.
- Allow full-session fork and fork from a specific completed turn.
- Persist fork lineage for UI display, future fork-tree queries, and debugging.
- Apply a deterministic default child title:
`<parent title>(fork<num>)`.
- Keep the first implementation compatible with current Agent GUI activity
snapshots and message pagination.
## Non-Goals
- Git branch or worktree creation during fork.
- Fork support for non-Codex providers in the first version.
- Cross-provider fork.
- Fork tree visualization beyond showing the direct parent relationship.
- Mutating, truncating, or rolling back the parent session.
## User-Facing Behavior
Agent GUI provides two actions:
- `Fork conversation`: forks the whole current conversation.
- `Fork from here`: forks through the selected completed turn.
After a successful fork, Agent GUI refreshes activity state and activates the
child session. The child header shows that it was forked from the parent session
and offers a link/action to open the parent.
When the selected turn is still running, `Fork from here` is disabled.
## Title Rules
When the request does not provide an explicit child title, tuttid generates the
title server-side:
```text
<parent title>(fork<num>)
```
Rules:
- `num` starts at `1`.
- The number increments across direct forks from the same parent session.
- If a generated title already exists in the workspace, tuttid increments until
the title is unique.
- If the parent title is empty, tuttid first resolves the same fallback title
used for session display, then appends the suffix.
- If the request provides an explicit title, tuttid uses it without applying the
fork suffix.
- Forking an already-forked session appends to the current parent title. For
example, `Build login(fork1)` forks to `Build login(fork1)(fork1)`.
## Backend API
Add:
```http
POST /v1/workspaces/{workspaceID}/agent-sessions/{agentSessionID}/fork
```
Request schema:
```json
{
"requestId": "uuid-for-idempotency",
"targetAgentSessionId": "optional-child-session-id",
"lastTurnId": "optional-completed-turn-id",
"title": "optional child title",
"visible": true,
"settings": {
"model": "optional override",
"reasoningEffort": "optional override",
"permissionModeId": "optional override",
"planMode": false,
"browserUse": true,
"computerUse": true
}
}
```
Response schema:
```json
{
"session": {
"...": "WorkspaceAgentSession"
},
"fork": {
"sourceAgentSessionId": "parent-tutti-session-id",
"targetAgentSessionId": "child-tutti-session-id",
"sourceProviderSessionId": "parent-codex-thread-id",
"targetProviderSessionId": "child-codex-thread-id",
"provider": "codex",
"lastTurnId": "optional-completed-turn-id",
"forkedAtUnixMs": 1782892800000
}
}
```
The endpoint is idempotent by `(workspaceID, requestId)`. Repeating a completed
fork request returns the existing child session and lineage record.
## Runtime Contract
Extend the agent runtime controller with a fork operation:
```go
Fork(ctx context.Context, input RuntimeForkInput) (RuntimeForkResult, error)
```
Suggested service-level input:
```go
type RuntimeForkInput struct {
WorkspaceID string
SourceAgentSessionID string
TargetAgentSessionID string
Provider string
SourceProviderSessionID string
Cwd string
Settings ComposerSettings
LastTurnID string
Title string
Visible *bool
}
```
Suggested service-level result:
```go
type RuntimeForkResult struct {
Session RuntimeSession
Messages []RuntimeMessage
Fork RuntimeForkLineage
}
```
`RuntimeMessage` should represent provider-derived child history in a form that
the tuttid service can persist into `workspace_agent_messages`. If the adapter
does not return messages, the service falls back to copying parent cached
messages through the fork boundary.
## Codex Provider Behavior
For the Codex provider, the adapter calls app-server `thread/fork`.
Mapping:
- Parent Tutti `providerSessionId` becomes Codex `threadId`.
- Optional Tutti `lastTurnId` becomes Codex `lastTurnId`.
- Effective model, model provider, cwd, runtime workspace roots, approval
policy, sandbox or permissions profile, config overrides, base instructions,
developer instructions, and ephemeral setting are forwarded the same way
Codex TUI builds `ThreadForkParams`.
- Response `thread.id` becomes the child Tutti `providerSessionId`.
- Response thread metadata updates the child runtime session.
If `thread/fork` returns turns, the adapter should return them for persistence.
If the fork response excludes turns or the running Codex version omits them, the
adapter should read the child thread with turns before returning when supported.
Non-Codex providers return a structured unsupported-provider error in the first
version.
## Storage
Add a dedicated lineage table. `runtime_context_json` may cache display fields,
but lineage facts should not live only in JSON.
```sql
CREATE TABLE IF NOT EXISTS workspace_agent_session_forks (
workspace_id TEXT NOT NULL,
child_agent_session_id TEXT NOT NULL,
parent_agent_session_id TEXT NOT NULL,
child_provider_session_id TEXT NOT NULL DEFAULT '',
parent_provider_session_id TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT '',
fork_turn_id TEXT NOT NULL DEFAULT '',
fork_request_id TEXT NOT NULL DEFAULT '',
fork_request_fingerprint TEXT NOT NULL DEFAULT '',
forked_at_unix_ms INTEGER NOT NULL,
PRIMARY KEY (workspace_id, child_agent_session_id),
UNIQUE (workspace_id, fork_request_id),
FOREIGN KEY (workspace_id, child_agent_session_id)
REFERENCES workspace_agent_sessions(workspace_id, agent_session_id)
ON DELETE CASCADE,
FOREIGN KEY (workspace_id, parent_agent_session_id)
REFERENCES workspace_agent_sessions(workspace_id, agent_session_id)
ON DELETE CASCADE
);
```
Add indexes for parent lookup:
```sql
CREATE INDEX IF NOT EXISTS idx_workspace_agent_session_forks_parent
ON workspace_agent_session_forks(workspace_id, parent_agent_session_id);
```
The child `workspace_agent_sessions` row stores:
- new `agent_session_id`
- same provider
- child `provider_session_id`
- effective settings
- effective cwd
- generated or explicit title
- visible state
- status from runtime fork result
- runtime context with cached display metadata
Suggested cached runtime context keys:
```json
{
"forkedFromAgentSessionId": "parent-tutti-session-id",
"forkedFromProviderSessionId": "parent-codex-thread-id",
"forkedFromTurnId": "optional-completed-turn-id",
"forkParentTitle": "parent title",
"forkedAtUnixMs": 1782892800000,
"forkMessageSource": "provider-turns"
}
```
## Message Initialization
The standard path initializes child messages from provider history:
1. Call Codex `thread/fork` without `excludeTurns` where possible.
2. If the response contains turns, project those turns to
`workspace_agent_messages`.
3. If the response does not contain turns, read the child thread with turns and
project those turns.
4. If provider turns are unavailable, copy parent Tutti messages through the
fork boundary as a fallback and set
`runtimeContext.forkMessageSource = "parent-cache-fallback"`.
Fallback copy rules:
- Copy only messages from completed persisted turns.
- If `lastTurnId` is empty, copy all completed parent messages.
- If `lastTurnId` is present, copy through that turn inclusively.
- Do not copy active, in-progress, failed-in-progress, or locally optimistic
overlay messages.
- Reuse message ids only under the child `agent_session_id`; the existing unique
key includes `agent_session_id`.
- Recompute message versions for the child session so pagination cursors stay
local to the child.
## Service Flow
The operation is a saga because provider fork and local SQLite persistence are
not one atomic transaction.
1. Normalize and validate `workspaceID`, source `agentSessionID`, and
`requestId`.
2. Return an existing result if `(workspaceID, requestId)` has already completed.
3. Load the parent session and verify provider is `codex`.
4. Verify the parent has a non-empty `providerSessionId`.
5. Verify the parent session is idle (`ready` or `completed`). Reject a
`working` parent before calling the provider so a whole-conversation fork
cannot race an active turn.
6. Validate `lastTurnId` when present:
- it must exist in parent messages or provider turns;
- it must not identify an in-progress turn.
7. Resolve child `agentSessionId`.
8. Resolve child title with the default title rules.
9. Resolve effective settings and cwd.
10. Call `RuntimeController.Fork`.
11. Persist child `workspace_agent_sessions`.
12. Persist `workspace_agent_session_forks`.
13. Persist child messages from provider-derived history or fallback copy.
14. Publish activity/session events.
15. Return the child session and lineage response.
If provider fork succeeds but local persistence fails, tuttid returns failure
and logs the orphan provider thread id with enough context for cleanup or
diagnosis. It must not report a successful fork unless local child session and
lineage persistence completed.
## GUI Integration
Extend `AgentGUIRuntime` with:
```ts
forkSession(input: AgentActivityForkSessionInput): Promise<AgentActivityForkSessionResult>
```
Desktop adapter calls the new tuttid endpoint.
Agent GUI actions:
- Conversation menu calls `forkSession` without `lastTurnId`.
- Turn/message menu calls `forkSession` with the selected completed `turnId`.
- On success, reload or merge the activity snapshot and activate the child
session.
- Header displays a compact parent indicator for forked sessions.
The GUI should not compute default fork titles. It may pass an explicit title
only when the user edits one.
## Errors
Use stable app error codes:
- `agent.fork_unsupported_provider`: provider has no fork implementation.
- `agent.fork_parent_not_found`: source session is missing.
- `agent.fork_provider_session_missing`: parent has no provider session id.
- `agent.fork_session_not_idle`: parent has an active turn.
- `agent.fork_turn_not_found`: `lastTurnId` was not found.
- `agent.fork_turn_in_progress`: `lastTurnId` identifies an active turn.
- `agent.fork_target_conflict`: requested child session id already exists.
- `agent.fork_request_conflict`: request id exists with different parameters.
- `agent.fork_provider_failed`: provider `thread/fork` failed.
- `agent.fork_persistence_failed`: provider fork succeeded but local persistence
failed.
HTTP mapping:
- `400` for invalid input and missing/invalid `lastTurnId`.
- `404` for missing parent session.
- `409` for a non-idle parent, active turn, target conflict, or request conflict.
- `422` for unsupported provider.
- `502` for provider fork failure.
- `503` for unavailable runtime or storage dependencies.
## Tests
Backend unit tests:
- Codex adapter sends `thread/fork` with parent thread id.
- Codex adapter passes `lastTurnId`.
- Codex adapter maps response `thread.id` to child `providerSessionId`.
- Runtime controller rejects unsupported providers.
- Service creates child session, lineage row, and child messages.
- Service applies title rule `<parent title>(fork<num>)`.
- Service increments fork number for repeated direct forks.
- Service respects explicit child title.
- Service rejects a whole-conversation fork while the parent is working.
- Service rejects in-progress turn fork.
- Service handles idempotent request replay.
- Service rejects request id reuse with different parameters.
- Service reports provider success plus local persistence failure as failure.
Storage tests:
- Migration creates lineage table and parent index.
- Deleting a child session deletes its lineage row.
- Parent lookup returns all direct child forks.
Frontend tests:
- Conversation menu triggers fork without `lastTurnId`.
- Turn menu triggers fork with selected `turnId`.
- Running turn disables `Fork from here`.
- Successful fork activates the returned child session.
- Forked session header shows parent title/indicator.
## Rollout Plan
1. Add storage migration and repository methods for fork lineage.
2. Add runtime fork types and controller method.
3. Implement Codex adapter `thread/fork`.
4. Add tuttid service and API endpoint.
5. Generate OpenAPI clients.
6. Add desktop adapter/runtime method.
7. Add Agent GUI actions and parent indicator.
8. Add tests across runtime, service, API, storage, and GUI.
## Current Decisions And Future Extensions
- The first version logs orphan Codex thread ids when local persistence fails.
A cleanup command can be added later, but it is not part of this delivery.
- The first version shows only the direct parent indicator in Agent GUI. A fork
tree can be built later from `workspace_agent_session_forks`.
- The first version accepts Tutti canonical turn ids as `lastTurnId`. If Codex
provider turn ids diverge from Tutti turn ids in a future protocol, tuttid
should add an explicit mapping layer rather than accepting ambiguous ids.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,621 @@
# Agent Goal Control Design
Status: implemented, independently reviewed, and accepted on
`codex/goal-control-root-fix`
## Problem
Goal is a session-level, long-running provider capability. It may exist while
no Turn exists and may cause the provider to create multiple Turns over time.
The old path classified `/goal` after allocating a local Turn ID. A direct
control could therefore attach messages and loading state to a Turn the
provider never started. Immediate `/goal clear` left that phantom Turn without
a terminal event, so the first reply stayed thinking and the session stayed
running.
The invalid model was:
```text
Goal == specially shaped Turn
```
The durable model is:
```text
Session owns Goal state and GoalControlOperation records
Goal may cause zero or more provider-created Turns
Turn optionally records the Goal operation/revision that caused it
```
## Architecture
```mermaid
flowchart LR
GUI["Agent GUI goal command"] --> API["Typed Goal API"]
API --> SAGA["Goal control saga"]
SAGA --> GS["Durable Goal state<br/>desired / observed / revision"]
SAGA --> OP["Durable operation<br/>prepared / dispatched / terminal"]
FENCE["Exact generation fence<br/>operation + revision + repair epoch"] --> SAGA
FENCE --> ADAPTER
SAGA --> ADAPTER["Provider GoalAdapter"]
ADAPTER --> PROVIDER["Codex or Claude Code"]
PROVIDER --> EVENTS["Provider observations and lifecycle events"]
EVENTS --> ADAPTER
ADAPTER --> RECONCILE["Goal reconciliation"]
RECONCILE --> GS
PROVIDER --> TURN["Zero or more real Turns"]
TURN --> ACTIVITY["Turn lifecycle persistence"]
OP -. "operationId + revision" .-> TURN
```
Ownership rules:
- `workspace_agent_session_goals` is upper-layer durable business state.
- `workspace_agent_goal_control_operations` is the control saga log. It has no
`turn_id` foreign key.
- `workspace_agent_goal_generation_fences` durably rejects one exact
operation/revision/repair-epoch generation. Its delivery status does not
weaken the fence: row existence is the admission rule.
- Provider state is observation, not a replacement for desired state.
- `workspace_agent_turns` is created only by an explicit Turn lifecycle
transition. A message cannot manufacture a Turn.
- A Goal audit message is session-level and has an empty Turn ID.
## State Model
Goal state contains:
- `desired`: latest upper-layer intent.
- `observed`: latest normalized provider observation.
- `revision`: monotonic desired-state version.
- `tombstoned`: durable clear intent; a stale non-empty observation cannot
resurrect it.
- `syncStatus`: `pending`, `applying`, `synced`, `diverged`, `unknown`, or
`failed`.
- `pendingOperationId`, evidence, error, and observation timestamps.
- `startedAtUnixMs` is the durable start of one Goal generation. Active UI
elapsed time derives from it, while terminal observations may add
`durationMs`; renderer mount time is never Goal timing authority.
Each operation contains a stable operation ID, session identity, goal revision,
action, objective, status, evidence, error, and timestamps. It never claims a
Turn in advance.
## Turn Relationship
Turn provenance is immutable after first classification:
- `user_prompt`
- `goal_arm`
- `goal_continuation`
- `provider_initiated`
- `legacy_unknown`
A Goal-created Turn records `sourceGoalOperationId` and
`sourceGoalRevision`. Steer is input on an existing Turn and is not an origin.
Delayed continuation scheduling captures the revision and exits before
provider handoff when a newer set/clear has superseded it. Ordinary Goal
supersession does not cancel a started Turn. An explicit exact-generation
fence is different: it blocks that provider generation before canonical
adoption and may cancel an already canonical Turn only when the Turn's
immutable source operation, revision, and repair epoch all match. A fence never
uses the session's current active Turn as a substitute for provenance and
never cancels or clears a newer Owner generation.
### Exact Goal-generation fence
A higher layer may revoke the authorization/routing relation that originally
submitted a shared Goal. That relation is not a Session, Turn, or provider
execution. It must retain the accepted Goal operation identity and call
`FenceGoalGeneration` with a stable revocation submit ID. Host first persists
the exact target in `workspace_agent_goal_generation_fences`; only then does it
attempt runtime quiescence or a revision-conditional Goal clear.
The fence has two independent facts:
- `IntentAccepted`: the durable row exists and Host owns retries.
- `Settled`: runtime admission, exact-turn cancellation when applicable, and
the conditional clear have completed.
No provider response is interpreted as success. A pending fence survives
desktop/daemon restart, while a completed fence remains durable and is
restored whenever the provider session is resumed. Recovery always handles
fences before ordinary Goal operations so the revoked target cannot replay
first. Recovery does not resume an offline provider merely to deliver a fence:
it conditionally prepares the local clear, supersedes the revoked operation,
and waits. The runtime Controller retains every delivered exact fence across
idle connection release and installs the retained set into a replacement
adapter connection before dispatching the user operation that caused the
reconnect. Fence installation failure closes the unprotected connection
instead of leaving it available for dispatch. A later user-triggered resume
also restores the durable fence set before returning. Background fence/clear
calls carry a require-live contract that is checked under the runtime lifecycle
lock, closing the liveness-check/reconnect race. Runtime adapters key their
in-memory gate by
`(operationId, revision, repairEpoch)`: Codex rejects the matching continuation
before adoption, and Claude Code suppresses the matching provider Turn and its
later events until terminal. Other generations continue normally.
Provider cancellation acceptance is not fence settlement. If an exactly
matching canonical Turn exists, the fence stays pending after `CancelTurn`
acceptance and completes only after canonical terminal evidence arrives.
Fence-driven cancellation is also require-live: a disconnect between
installing the fence and cancel delivery leaves the durable Turn and fence
pending instead of resuming the provider just to cancel it.
Desktop startup may independently settle a stale pre-restart Turn as
`interrupted`; the retained fence is then free to finish without deleting the
Session or Turn.
### Exact Goal-generation provenance ledger
Codex identifies a Goal-created Turn by correlating the provider-authored Goal
generation returned by `thread/goal/set` with the same generation on the
turn-scoped `thread/goal/updated` notification. This correlation cannot rely on
a bounded process-local cache: after eviction or daemon restart, a delayed old
generation could otherwise inherit the current Goal operation.
The durable ledger key is:
```text
(workspaceId, agentSessionId, sessionCreatedAtUnixMs,
providerSessionId, generationFingerprint)
```
`generationFingerprint` is a fixed-length SHA-256 digest of canonical
provider-authored generation material (`threadId`, `createdAt`, `updatedAt`,
and `objective`). The objective is never stored in the ledger key or logs.
The value is the immutable `(operationId, revision, repairEpoch)` identity.
`sessionCreatedAtUnixMs` is the session-incarnation fence: bind must match the
currently persisted, non-deleted session row, so an ACK from before
`ClearSessions` cannot attach to a same-ID recreated session.
First bind wins; rebinding the same fingerprint to a different identity
atomically replaces the identity with a permanent `ambiguous` tombstone.
Ambiguous generations are never adopted, and a collision observed while
binding fails closed by terminating that provider session.
The adapter performs a bounded synchronous durable bind before accepting a
successful Goal mutation and an exact durable lookup before adopting a
provider-created Turn. Missing evidence remains pending only for the bounded
provenance grace window; ledger failure closes the provider rather than
guessing. The in-memory maps are only a working set for the current generation
and unmatched/active Turns. Handoff, settlement, rejection, and expiry release
Turn evidence and prune unprotected bindings, so more than 256 continuations
cannot permanently degrade a valid long-running Goal. Session delete, batch
delete, and clear explicitly remove ledger rows even when SQLite foreign keys
are disabled.
```mermaid
sequenceDiagram
participant U as GUI
participant S as Service
participant DB as Goal Store
participant A as GoalAdapter
participant P as Provider
participant T as Turn Store
U->>S: set / clear / pause / resume
S->>DB: prepare operation + increment revision
DB-->>S: operationId, revision, desired
S->>DB: mark dispatched/applying
S->>A: ApplyGoal(operationId, revision, action)
A->>P: provider-native goal request
P-->>A: acknowledgement/observation/evidence
A-->>S: normalized result
S->>DB: complete or fail operation
opt provider starts goal work
P-->>A: turn_started
A->>T: create real Turn(origin, operationId, revision)
end
```
Clear is valid whether or not a Turn exists. It updates the Goal entity and
operation; it does not allocate a Turn, emit `turn_started`, or cancel any
already accepted or started Turn. Provider-native clear affects future Goal
scheduling and continuation only.
## Provider Adapter
All providers implement one semantic boundary:
- capability discovery
- apply Goal action
- reconcile/query observation where supported
- normalize provider state
- typed `/goal` classification before Turn allocation
Codex uses thread goal RPCs and an authoritative goal query. Claude Code has no
equivalent query API, so the adapter forwards native slash commands. With the
Claude Agent SDK Goal contract, the sidecar accepts the SDK `active_goal`
message and the native `/goal` top-level `goal_status` attachment, then emits
one normalized `goal_observed` event. The SDK stream may omit the attachment,
so the sidecar incrementally tails only newly appended root transcript rows
and explicitly drains them at root result and provider-idle boundaries. A
non-null `active_goal` or `goal_status.met=false` is active, while
`goal_status.met=true` is complete. If the provider becomes idle after the
final drain with no terminal verdict, the active Goal becomes `blocked` rather
than remaining falsely running. A null `active_goal` means the native Goal hook
was cleared; the adapter uses the exact command action plus its previous
observation to distinguish an explicit clear from provider-reported
completion. An ordinary `turn_completed` is never Goal evidence.
Claude command consumption is a separate control transition. The sidecar emits
`goal.control_applied` with immutable operation ID, revision, repair epoch, and
action. Controller routes that internal event directly to the Host Goal lane;
Host validates the current durable operation fence and completes it. Applied
evidence is not stored in session runtime context and is not reconstructed from
session snapshots.
## Synchronization And Repair
Top-down:
1. Persist desired state and operation atomically.
2. Mark applying before calling the provider.
3. Pass operation ID and revision through the adapter.
4. Persist provider result and evidence.
Bottom-up:
1. Normalize provider runtime snapshots.
2. Update only observed state and evidence.
3. While an operation is pending, keep `syncStatus=applying`.
4. Never replace desired state or clear a tombstone from an observation.
5. Capture the canonical Goal revision before asynchronously adopting a
provider-authored generation, and reject the adoption if a newer mutation
serialized first.
6. Route command-application evidence through the Host Goal state machine,
independently from public session metadata.
Calibration APIs:
- `GET /v1/workspaces/{workspaceID}/agent-sessions/{agentSessionID}/goal`
- `POST /v1/workspaces/{workspaceID}/agent-sessions/{agentSessionID}/goal/reconcile`
## Required Invariants
1. Goal control never preallocates a Turn ID.
2. A non-empty message Turn ID must reference an existing Turn.
3. A control operation is durable before provider dispatch.
4. Revision is monotonic and invalidates stale continuation scheduling before
provider acceptance, never an already accepted or started Turn.
5. Turn origin is durable and immutable.
6. Goal clear cannot cancel any current Turn, including `user_prompt`,
`goal_arm`, or `goal_continuation`.
7. Provider observations cannot erase newer desired state or tombstones.
8. Provider-specific capabilities and evidence stay behind `GoalAdapter`.
9. A provider-authored Goal adoption is conditional on the revision observed
when it entered the adoption lane; actor wait time cannot make stale
evidence appear newer.
10. Session runtime context cannot settle a Goal control operation; only an
exact Host-validated lifecycle observation or authoritative reconciliation
may do so.
## Compatibility
Existing sessions are backfilled from stored session goal metadata at revision
zero. Existing Turns become `legacy_unknown`. Provider-specific compatibility
entry points remain for focused tests, while production controller paths use
the semantic adapter and typed Goal API.
## Delivery Plan And Quality Gates
The root fix is delivered in three ordered stages. A stage is complete only
after its implementation, an independent review, review corrections, and the
listed regression checks all pass. Later stages must not be used to waive an
earlier stage's acceptance criteria.
### Stage 1: Close The Original Race
Stage status: implemented, independently reviewed, and accepted.
#### Objective
Make every Goal command enter one durable control path, and make provider
acknowledgement, provider execution, and Turn provenance unambiguous. This
stage must close the reported `set -> immediate clear` hang without depending
on a restart or a repair worker.
#### Work
1. Move typed `/goal` classification to the Service/API boundary. A typed
Goal command must enter the same Goal control saga as the dedicated Goal
API before a submit claim or Turn ID is allocated. `Controller.Exec` must
not provide a second, non-durable Goal execution path.
2. Keep `/input` Turn-producing. For a new session, either create an empty
session and then call Goal control or expose an atomic
`CreateSessionWithGoal` operation. Do not return an empty Turn ID through a
response contract that promises a Turn.
3. Add an operation-aware Claude Goal dispatcher before the normal prompt
queue. Each item carries immutable `operationId`, `revision`, `action`, and
optional provider Turn ID.
4. Coalesce an undispatched older `set` when a newer `clear` arrives. If the
old set has already been handed to the SDK, retain its identity and allow
its Turn to settle naturally. Apply the latest clear as the desired state so
no later continuation is scheduled; revision supersession alone must never
interrupt the accepted Turn.
5. Split provider phases. Sidecar `ok` means only `accepted`; it must not mark
the Goal operation `synced`. Persist evidence for `accepted`,
`execution_started`/`applied`, and `completed` where the provider exposes
those transitions. A clear remains applying until its effect is consumed or
observed.
6. Replace mutable session-global Goal provenance with an immutable command or
provider-Turn association. A delayed Turn must keep the operation and
revision that caused it even after a newer command changes session state.
7. Do not translate Claude Goal clear into either generic or precise Turn
cancellation. Exact quiesce is reserved for missing/ambiguous provenance;
explicit user cancellation remains a separate Turn operation.
#### State And Sequence Contract
```mermaid
sequenceDiagram
participant U as Client
participant S as Goal Service
participant D as Goal Dispatcher
participant P as Provider
participant T as Turn Store
U->>S: set(revision 1)
S->>S: persist operation 1
S->>D: dispatch(op 1, rev 1, set)
D-->>S: accepted
U->>S: clear(revision 2)
S->>S: persist operation 2 + tombstone
S->>D: supersede future scheduling for op 1, dispatch op 2 clear
alt set was not handed to provider
D->>D: remove set from queue
else set already crossed provider handoff
P-->>D: turn_started(providerTurn, op 1, rev 1)
D->>T: create Turn(goal_arm, op 1, rev 1)
Note over P,T: Turn continues to its natural terminal
end
P-->>D: clear applied evidence
D-->>S: applied(op 2, rev 2)
S->>S: mark current revision synced
```
#### Acceptance Gate
- A real integration-style test executes `set`, receives only the sidecar
scheduling ACK, immediately executes `clear`, and proves the accepted Turn
reaches its natural terminal, clear prevents another continuation, and the
session does not remain running.
- The test must not inject a synthetic `turn_canceled` event to make the queue
advance.
- Typed `/goal` and the dedicated Goal API create the same durable operation
records and allocate no Turn before `turn_started`.
- A delayed revision-1 Turn cannot be labelled with revision 2 or operation 2.
- Goal clear leaves the current Goal or user Turn running and only prevents
future Goal continuations.
- A fully proven superseded Turn is adopted with its original operation and
revision; only missing or ambiguous provenance is quiesced.
- Relevant Go, sidecar, API, generated-client, typecheck, and lint checks pass.
### Stage 2: Make The State Machine Recoverable
Stage status: implemented, corrected through five independent review gates, and accepted.
#### Objective
Make ordering and convergence correct under overlapping requests, slow
provider RPCs, process crashes, retries, and manual reconciliation.
#### Work
1. Introduce one per-session `GoalActor` (or an equivalent single-writer
dispatcher) that serializes set, clear, reconcile, continuation/nudge, and
compensation. Provider mutations may not bypass this ordering boundary.
2. Apply a revision fence before provider dispatch and after provider result.
A stale result is evidence only and may not update current desired or sync
state. If a stale provider mutation could already have taken effect, enqueue
compensation toward the latest desired revision.
3. Make reconciliation transactional. Its compare-and-swap predicate includes
session ID, desired revision, pending operation ID, and observation time.
Retry on CAS conflict instead of overwriting a concurrent transition.
4. Promote Goal operations from an audit log to a recoverable outbox with
owner, lease expiry, attempt count, next-attempt time, and provider phase.
The delivery age/attempt budget begins at the first durable `dispatched`
transition and is immutable across ordinary retries. A `prepared` operation
whose runtime is unavailable has not crossed the Provider boundary and may
remain pending; a dispatched/applying operation must deterministically fail
when its generation budget expires. A stale-result repair wake creates a new
delivery generation and resets these baselines.
5. Add startup and periodic recovery. Claim expired non-terminal operations and
determine the next action from durable desired state and provider evidence.
Codex uses authoritative query; Claude replays idempotent clear and resumes
set only from a persisted safe provider phase.
6. Do not use a request context to persist a provider result after the request
has been canceled. Completion/failure persistence uses a bounded service
context and surfaces storage errors.
7. Allow reconciliation to CAS-complete a matching pending operation when
authoritative evidence proves convergence.
8. Separate control convergence from Goal lifecycle. For the same objective
and revision, lifecycle states such as `complete`, `blocked`, or limited can
be control-converged even though they are not `active`.
#### State Machine
```mermaid
stateDiagram-v2
[*] --> prepared
prepared --> dispatched: actor claims operation
dispatched --> accepted: provider accepts command
accepted --> applied: provider evidence matches action
applied --> completed: durable convergence commit
prepared --> retry_wait: lease/error
dispatched --> retry_wait: lease/error
accepted --> retry_wait: lease/error or crash
retry_wait --> dispatched: recovery claim
dispatched --> superseded: newer revision before dispatch
accepted --> compensating: stale mutation may have applied
compensating --> completed: latest revision restored
prepared --> failed: permanent error
dispatched --> failed: permanent error
accepted --> failed: permanent error
```
Only `completed`, `superseded`, and `failed` are terminal. `accepted` is never
equivalent to synchronized.
#### Acceptance Gate
- A slow revision-1 provider result arriving after revision-2 clear cannot
change revision-2 desired, pending operation, or sync status.
- Concurrent reconcile and set/clear tests prove CAS conflict/retry behavior.
- Crash-point tests cover after prepare, after dispatch, after accept, and
after provider apply but before durable completion.
- Restart recovery drains or deterministically fails every expired operation;
no operation remains `applying` forever.
- Recovery never replays a non-idempotent Claude set from an unsafe phase.
- Terminal/limited provider lifecycle for the same Goal can be control-synced.
#### Deferred bounded-retention hardening
The reconcile inbox currently retains completed and failed request identities
for the lifetime of the session. A follow-up migration must add bounded,
incremental GC without weakening replay safety: completed rows retain a
documented request-id dedupe window; failed rows use a longer retention window
and are eligible only after the same revision has a durable terminal incident
summary/fence. The worker should prune a small fixed batch per tick and tests
must prove ACK replay remains idempotent inside the window and terminal audit
survives row removal. This is deliberately deferred from the terminal-fence
correction because retention changes deletion semantics and migration policy.
### Stage 3: Harden Protocol And Persistence Invariants
Stage status: implemented, corrected through independent review gates, and
accepted.
Batch A status: accepted. The runtime/reporter path keeps first-class
`SessionAuditEvent` records separate from Turn messages; the compatibility
HTTP/store envelope encodes them as `kind=session_audit` with no Turn ID, and
realtime projection publishes the dedicated `session_audit` event. Runtime/API
messages remain strictly Turn-scoped. Read-only external transcript import is
the only explicit historical compatibility path allowed to retain older
turnless rows; the import flag is internal and cannot be inferred from or
forged by the reported origin. Existing nullable legacy rows remain readable
and are never assigned a guessed Turn.
Batch B/C status: accepted. Interaction persistence rejects unknown Turns; a
provider-raised first interaction carries an explicit `provider_initiated`
Turn proposal and commits the matching Turn plus interaction atomically, while
conflict/replay keeps any existing origin unchanged. Clear, single-delete, and
batch-delete paths explicitly remove runtime-operation outbox/events and
operations, then Goal operations and Goal state, even with foreign keys
disabled. Ordinary system notices are Turn-scoped. `origin`,
`sourceGoalOperationId`, `sourceGoalRevision`, and `sourceGoalRepairEpoch`
survive event compatibility, durable reload, HTTP/realtime projection,
canonical reducers, and replay; historical `legacy_unknown` rows are never
reclassified.
Batch D status: accepted. Codex Goal generation provenance uses the exact
durable ledger described above, with session-incarnation fencing, permanent
ambiguity tombstones, bounded synchronous bind/lookup, and fail-closed handling
for storage failure, malformed/empty durable Goal responses, and malformed
turn-scoped Goal observations. More than 320 continuation Turns, restart,
delayed old generations, collisions, deletion, and same-ID session recreation
are covered by regression tests.
#### Objective
Remove compatibility loopholes that can recreate phantom Turns, lose Goal
records, or cause audit delivery to retry forever.
#### Work
1. Introduce a first-class session-level `SessionAuditEvent`, separate from
Turn messages. If wire compatibility requires one envelope, `turnId` is
nullable only when `kind=session_audit`; all Turn-scoped kinds still require
a real Turn.
2. Update activity compatibility, event projection, generated contracts, and
reporters so session audit events do not enter Turn lifecycle logic and do
not retry because a Turn ID is absent.
3. Enforce the unknown-Turn invariant for interactions as well as messages.
Provider-initiated interaction must explicitly create a
`provider_initiated` Turn before persisting the interaction.
4. Explicitly delete Goal operations and Goal state in `ClearSessions` before
deleting sessions. Correctness must not depend on SQLite foreign-key cascade
being enabled.
5. Preserve immutable origin and Goal source fields across all event,
persistence, compatibility, and projection paths. Legacy rows remain
`legacy_unknown`; they are not guessed into a Goal origin.
6. Document and test the final compatibility boundary for older clients and
stored sessions.
7. Persist exact provider Goal-generation provenance before reporting a
durable Goal mutation as applied. Adoption after restart must resolve the
same operation, revision, repair epoch, provider session, and session
incarnation; it may not fall back to the current in-memory Goal identity.
8. Fail closed when a durable provenance bind/lookup cannot be completed or a
provider response cannot produce a valid generation fingerprint. Empty
responses remain compatible only for provider actions whose contract
explicitly permits them, such as pause/resume status mirrors.
#### Acceptance Gate
- Session audit events round-trip through adapter, compatibility layer, event
protocol, persistence, and projection with no Turn ID and no retry loop.
- Every non-audit message or interaction with an unknown non-empty Turn ID is
rejected; no implicit Turn is manufactured.
- `ClearSessions` removes Goal operations and Goal state with
`PRAGMA foreign_keys=OFF`, and recreating the same session ID starts clean.
- Provenance survives daemon restart and event replay unchanged.
- Full Go tests, sidecar tests, client tests, typecheck, lint, generated-file
checks, and `git diff --check` pass.
#### Independent Review Corrections
The Stage 3 review found and closed the following blocking boundaries before
acceptance:
- a sticky revision-scoped terminal fence now protects every Goal observation
and sync-status write path;
- reconcile uses a durable two-phase inbox (`quiesce_pending` then
`finalized`), leases, poison-row isolation, bounded retries, and atomic
exhaustion escalation;
- provider recovery policy is exposed through adapter capabilities instead of
provider-name branches in the service;
- adopted Codex Goal Turns use a per-Turn processing mutex and atomic
pending/adopting/active handoff so buffered notifications cannot race settle;
- durable provenance bind is acknowledged before `applied`; missing ACK,
collision, malformed fingerprint material, or an empty durable set response
closes the provider session instead of guessing;
- the ledger key includes the durable session incarnation, preventing an old
ACK from binding after clear and same-ID session recreation.
#### Deferred Non-Blocking Hardening
- The HTTP Goal-provenance client contract exists for a future remote
control-plane reporter, but this repository has no matching server route.
Current production wiring uses the in-process activity projection and SQLite
ledger. A remote deployment must add bind/lookup routes and an end-to-end
contract test before enabling that client path.
- Reconcile-inbox completed/failed retention remains the bounded-GC follow-up
documented under Stage 2. It is not required for convergence correctness,
but should be implemented before unbounded session lifetimes become an
operational storage concern.
#### Verification Record
- affected Go packages: passed;
- Goal-focused store, service, and runtime race tests: passed;
- `@tutti-os/agent-activity-core`: 181/181 passed;
- `@tutti-os/client-tuttid-ts`: 53/53 passed;
- `@tutti-os/claude-sdk-sidecar`: 72/72 passed;
- TypeScript typecheck, Go/TypeScript lint, generated API/event/Codex protocol
checks, provider-catalog check, and `git diff --check`: passed.
## Final Completion Criteria
The design is complete only when all three stage gates pass and the following
cross-stage invariants hold:
1. Goal state is session-owned and durable; Turn state is an observed execution
consequence, never Goal identity.
2. There is exactly one production mutation path and one per-session ordering
authority for Goal control.
3. Every provider transition is tied to immutable operation/revision evidence.
4. An acknowledgement is not treated as application or convergence.
5. Desired state can be repaired after crash without resurrecting a cleared
Goal or canceling unrelated work.
6. No message, interaction, audit event, or compatibility path can fabricate a
Turn.
@@ -0,0 +1,779 @@
# Provider-Native Subagents
Status: accepted architecture, implementation in progress
This spec covers provider-native child agents only:
- Codex app-server multi-agent child threads.
- Claude Code SDK `Task` / subagent sessions and turns.
- ACP providers that expose an equivalent nested/background agent concept.
It does not cover Tutti launching another top-level AgentGUI session with
`tutti-dev agent start`, handoff mentions, or another Codex/Claude Code process.
It also does not turn Claude Code SDK task-system processes into child agents.
A detached `run_in_background` Bash shell, including one that merely tracks a
top-level session such as `tutti agent wait`, remains a root-owned tool launch.
Its provider task id is retained for explicit stop, but the process does not
create a child session, gate the root turn, or reserve an agent continuation.
## Product Contract
From the user's point of view, a root turn remains active while any
provider-native child agent created under that turn is still active.
- Root turn completion is gated by child-agent completion.
- User cancellation cancels the root turn and every child agent owned by that
turn.
- A new user message submitted while the root turn is active is appended to
the same in-flight work instead of creating a separate visible turn.
- Child-agent progress is presented under the root turn, not as a separate
conversation.
The product model is closest to Codex app-server multi-agent UX: the user sees
one root turn that can delegate work internally. The implementation must not
depend on Codex-specific event shapes.
## Entity Decision
The existing `WorkspaceAgentTurn` remains the correct top-level user-visible
unit. Provider-native child agents are subordinate `WorkspaceAgentSession`
entities. Being non-navigable or non-resumable does not make a child agent less
session-like: it still has provider identity, turns, messages, context,
interactions, cancel/guide behavior, and possibly its own child sessions.
The existing session model currently also implies a top-level user
conversation. That implication must be separated from session identity. A
child session is hidden from ordinary conversation navigation and is reached
through its immutable parent relationship.
Target model:
```text
WorkspaceAgentSession (root, user-visible conversation)
-> WorkspaceAgentTurn (root, user-visible turn)
-> WorkspaceAgentSession[] (child, hidden subordinate sessions)
-> WorkspaceAgentTurn[]
-> messages / interactions
-> WorkspaceAgentSession[] (nested child sessions)
```
Every session needs an explicit kind of `root` or `child`. A child
session also needs durable parent and root fields:
- `parent_agent_session_id`
- `parent_turn_id`
- `parent_tool_call_id`
- root `agent_session_id` and root `turn_id` for nested child lookup and
cancellation
- provider handles and aliases such as `threadId`, `taskId`, `agentId`, and
`parentToolUseId`
The parent relationship describes which session turn and delegation tool
created the child. It does not make child messages or interactions belong to
the root turn. Their canonical owner remains the exact child session and child
turn; root-turn projections join them through the recorded parent and root
fields.
There is no separate `WorkspaceAgentSubAgentRun` entity in the target model.
Each spawned child session has exactly one immutable creator relationship to
one parent session, parent turn, and delegation tool call. It is never
re-parented, reused by another parent turn, or split into multiple independently
settled delegations. Later guidance may create more turns inside that same child
session, but it does not create another ownership edge. A separate run entity
would therefore duplicate the child session's identity and lifecycle.
Child lifecycle uses the existing turn phase and outcome vocabulary. The
daemon creates a submitted child turn when delegation is accepted, before all
provider aliases are necessarily known, and binds later native identifiers to
that session/turn. Do not duplicate child lifecycle into a second child-agent
status field.
## Session And Turn Semantics
`WorkspaceAgentSession` is the identity and context boundary for one agent,
whether it is a root agent or a child agent. `WorkspaceAgentTurn` records one
turn inside that session.
- A root turn is initiated by a user submission and remains the user-visible
turn.
- A child turn is initiated by delegation from its parent turn or by later
guidance sent to that same child session.
- Root and child turns use the same phase, outcome, message, interaction, and
exact-turn cancellation model. Trigger source does not create a second turn
entity type.
- A child session may contain multiple sequential turns, but its immutable
creator relationship does not change and every child turn remains under the
same root session and root turn.
- Child messages and interactions belong to the exact child turn that produced
them. The root turn aggregates them for lifecycle and presentation without
rewriting their ownership.
- Claude SDK permission callbacks running inside a child must preserve the
callback `agentID` as the provider `agentId` alias. The Claude adapter resolves
that alias to the already-recorded child session and turn before emitting the
canonical interaction. The callback's provider/root turn id is not sufficient
evidence of interaction ownership.
The existing API description of `WorkspaceAgentTurn` as universally
"user-submission-driven" must therefore be broadened. That phrase describes a
root turn only, not the shared turn entity.
## Session Read Contract
The shared `WorkspaceAgentSessionResponse` continues to represent exactly one
session:
```text
WorkspaceAgentSessionResponse
session: WorkspaceAgentSession
```
Create, update, interactive-response, and other commands reuse this response
and must not load or return an entire child-session tree. The session entity
itself carries its `root` or `child` kind and, for a child, the immutable root
and parent relationship fields.
Only the session detail read uses a larger response:
```text
WorkspaceAgentSessionDetailResponse
session: WorkspaceAgentSession
childSessions: WorkspaceAgentSession[]
```
`GET /v1/workspaces/{workspaceID}/agent-sessions/{agentSessionID}` returns this
detail response. `childSessions` contains every nested child session below the
requested session as a flat collection. Clients reconstruct the tree from each
child's parent fields; the API does not define a recursive session DTO and does
not infer relationships from transcripts or display order. Reading a root
therefore restores its complete child-session tree in one durable snapshot,
while reading a leaf child returns an empty collection.
Messages remain independently addressable through the existing
`GET .../agent-sessions/{childSessionID}/messages` endpoint. Ordinary session
lists, section lists, pinned lists, search, and conversation navigation return
root sessions only. Child sessions are reached through the detail response or
by their exact session ID.
## Root Turn Lifecycle
The root turn lifecycle is controlled by both the root provider turn and every
nested child turn created under that root turn.
Rules:
- A root provider turn terminal event is not enough to settle the root
`WorkspaceAgentTurn` if any nested child turn is still submitted, running,
waiting, or settling.
- Normalize that provider terminal as an internal
`root_provider_turn_completed` event. The event records only that the current
root provider turn reached a terminal; it must not claim that the root
`WorkspaceAgentTurn` completed or encode the transient condition "completed
with children still running."
- The daemon reducer consumes this event and durably retains the root provider
turn's pending outcome. If child turns are still active, the root turn remains
`waiting`. This internal event does not need a separate AgentGUI projection
or a new user-visible turn phase.
- Provider-native workflow state is orthogonal to this gate. In particular,
Claude Goal `complete` means only that Claude considers its native goal met;
it is valid for the durable state to be `goal=complete`, canonical root
`turn=waiting`, and one or more child turns `running`.
- Composer availability, user-visible completion, and permission to create a
new canonical turn derive only from the `services/tuttid` root turn. Goal
status must not settle the root, clear its active-turn reference, or unlock
those surfaces.
- A child terminal event settles only the exact child turn. It must not choose
a root outcome by itself. After that child transition commits, the same
transaction re-evaluates the root gate and may apply an already-retained
root provider outcome when no active children remain.
- A failed child turn does not automatically fail the root turn. Child outcomes
participate in the terminal-state gate, while the root outcome remains owned
by the root agent. A root turn may complete successfully while one or more
child lanes visibly report failure.
- When the root provider turn and all nested child turns are terminal, the root
`WorkspaceAgentTurn` may apply the retained root provider turn outcome and
settle. If the root agent continues later, its next provider turn remains
attached to the same root `WorkspaceAgentTurn`; it does not create another
root `WorkspaceAgentTurn`.
- If the user cancels, the root turn settles as canceled after cancellation
has been requested for both root and child provider handles. Late child
events after cancellation must be reconciled into terminal child state, not
resurrect the root turn.
- A matching root provider terminal may arrive after the canonical root was
atomically settled as canceled. Persist that late provider terminal into the
root-provider lifecycle fields without changing, republishing, or reopening
the canonical root turn. A settled/canceled canonical root must not retain a
stale `root_provider_turn_phase=running` after that terminal is observed.
- If a child approval or interactive prompt is pending, its canonical identity
remains the child session/turn/request tuple, while the root turn remains
active and presents the interaction through its aggregate projection.
- Canonical interaction ownership and provider transport correlation are
separate identities. Persist, render, submit, and settle the interaction by
its canonical child session/turn/request tuple, but send the provider's
original turn id back when its protocol uses that id to find the pending
callback. A synthetic provider turn attached to the same canonical root turn
follows the same rule; its provider id must not be replaced with the
canonical root turn id on the response path.
The existing closed turn phase vocabulary can continue to expose the root turn
as `running` or `waiting`; the reason that it is still active should come from
child-session projections, not from inventing provider-specific turn phases in
the GUI.
## Root And Child Turn Ownership
Provider transports report provider facts; they do not decide whether a root
`WorkspaceAgentTurn` is complete.
- A provider sidecar emits its native root and child turn events without
querying Tutti entities or applying root/child rules.
- A provider adapter identifies the root, parent, and child session/turn IDs and
normalizes provider events. A root provider `turn_completed` becomes
`root_provider_turn_completed`; it must never become a canonical root
`turn.completed` inside the adapter.
- Root-provider lifecycle events are durable state inputs, not stream-only
diagnostics. The runtime reporting filter must forward both
`root_provider_turn_started` and `root_provider_turn_completed` to
`services/tuttid`; otherwise the provider can finish visibly while the
canonical root turn remains active forever.
- A persisted state patch mutates a canonical `WorkspaceAgentTurn` only when it
carries an explicit structured `Turn` transition. Runtime `TurnLifecycle`
and `SubmitAvailability` snapshots may enrich the live stream and a full
session snapshot, but they must never be interpreted as fallback canonical
turn facts. In particular, enriching a root-provider lifecycle event with a
runtime snapshot must not replay `root=running` after the durable root has
settled.
- `services/tuttid` owns the durable root/parent/child relationship and the root
turn completion rule. It records the root provider turn outcome, checks all
nested child turns, and emits the canonical root turn completion only when
the root turn can actually settle.
- The SQLite repository provides one atomic write for the root provider turn
outcome, child turn transition, root turn transition, active turn reference,
and resulting outbox events. It does not contain Claude-, Codex-, or
ACP-specific policy.
- Agent Activity and AgentGUI consume the resulting sessions, turns, messages,
and interactions. They do not reconstruct or override root turn completion.
- Session deletion follows the same ownership tree. Deleting a root or child
session atomically tombstones that session and all of its nested child
sessions, then removes their turns and interactions. A child must never
remain as an orphan after its ancestor is deleted.
This rule applies to every provider. When a root provider turn completes with
no active child turns, `services/tuttid` settles the root turn immediately. The
separate internal event exists so the same provider event remains correct when
child turns are active.
Event order must not change the result:
```text
root_provider_turn_completed -> active child turns remain -> root waiting
child turn terminal -> last active child -> root settles
child turn terminal -> root provider turn active -> root stays active
root_provider_turn_completed -> no active child turns -> root settles
```
Creating a child session and its initial child turn must be persisted before a
later root provider terminal is applied. A child discovered only after the root
turn settled is an event-normalization or ordering bug; it must not be handled
by resurrecting the root turn.
## Sending While Children Run
When the user submits another message while the root turn is active, the
command targets the root agent and the same root turn.
Default behavior:
- Append/guide the root provider turn when one is active.
- Let the provider decide how the root agent uses the guidance.
- Do not fan the text out to every child agent.
- Direct user targeting of one child agent is not supported in the current
product contract. It requires a separate future product decision rather than
a provider-specific shortcut.
Provider adapters must expose whether root-turn guidance is supported while
only child agents are running. If a provider cannot safely send the guidance on
the root session, the command must produce a clear pending/unsupported result
rather than silently starting a new root `WorkspaceAgentTurn`.
## Cancellation
Cancel is a fan-out operation over one root turn:
```text
cancel(root_turn_id)
-> cancel root provider turn when still live
-> traverse every nested child session owned by the root turn
-> cancel every non-terminal child provider handle / child turn
-> settle confirmed child turns canceled
-> settle unconfirmed child turns interrupted after timeout
-> settle root turn canceled
```
The fan-out target set is decided before entering the provider runtime:
- `services/tuttid` reads the durable session tree and records the exact
`(agentSessionId, turnId)` targets in the cancel operation.
- The runtime request carries `rootAgentSessionId` separately from `targets`.
The root id locates the live provider runtime; each target identifies a
canonical root or child turn.
- The runtime controller does not register or persist a second copy of child
business state. It only routes child targets through the root live session.
- The controller invokes the bounded provider cancel while the adapter still
owns the live root provider-turn handle. It cancels the local Exec context
only after that provider call returns. Local context cancellation is cleanup,
not a substitute for the native root interrupt.
- A provider adapter maps the supplied targets to native handles. It must not
scan its private registry to decide which child sessions belong to the
cancellation.
- The adapter returns the exact subset of supplied targets whose native cancel
operation was acknowledged. The controller forwards that evidence; it does
not translate a successful method call into turn settlement or synthesize a
missing child confirmation.
- Codex maps each supplied child session to its native thread. Claude SDK uses
its root-query cancel primitive when the target set includes the root; that
native operation covers the Task executions in the same query. Standard ACP
remains root-only until a provider explicitly supports child sessions.
- A Claude child-only target set does not interrupt the root query. The adapter
resolves each child to its SDK task identity (task id, agent id, or launching
tool-use id alias) and issues the SDK's targeted `stopTask`; the root provider
turn and every other task keep running. A target whose task is unknown or
already terminal is an idempotent no-op: it is simply not confirmed, and the
durable operation records it through the generic unconfirmed path.
When a Codex target set includes the root, the adapter stops the root provider
turn before waiting on child-thread interrupts. This closes the source of new
serial spawns first. It then interrupts every supplied child thread whose
native handle is still available. The execution-local cancellation boundary
remains necessary for a child whose spawn was already in flight and becomes
visible only after the root interrupt.
Preparing the durable root cancel operation also closes child creation for
that root turn. This boundary exists before provider I/O starts and remains in
effect while the operation is prepared or leased. A child first revealed after
the target snapshot must be rejected by persistence even if its event races
ahead of the cancel completion transaction; otherwise it could escape the
recorded target set and remain active below a canceled root.
`services/tuttid` maps acknowledged child targets to `canceled`, maps
unacknowledged child targets to `interrupted` when the bounded provider call
returns, and always maps the root target to `canceled` to preserve the user's
intent. The durable cancel operation, every target transition, pending
interaction supersession, and outbox data commit together. Recovery replays
the same idempotent target set instead of assuming that an earlier provider
side effect completed. This keeps provider invocation recoverable without
introducing a child-agent run entity or making the runtime controller another
session authority.
A canonical `canceled` turn must not retain a transport error such as
`context canceled`. That message describes how a local runtime goroutine was
stopped, not a failed business outcome, and the activity event contract permits
turn errors only for `failed` or `interrupted`. Cancel completion clears such
errors atomically. Transport projection also omits an error from canceled turns
so historical dirty rows cannot wedge reliable outbox publication.
An exact child-only cancel uses the same durable target contract without adding
the root as a cancel target. If that child was the final active child and the
root provider outcome was already retained, the cancel transaction also settles
the root from that retained outcome. The outbox publishes both turn updates and
reconciles the root runtime view; it does not relabel the root as canceled.
Late child output after cancellation is allowed to arrive, but it is appended as
historical child-session progress only when it can be scoped to an existing
child session and turn. It must not clear the root cancellation or create a
new root turn. A provider terminal caused by cancel, such as Claude SDK
`task_completed(status=stopped)` followed by `tool_failed(user_interrupt)`, is
not historical progress: after the exact child target has been closed by the
cancel operation, the adapter drops that terminal projection rather than trying
to relabel the durable child as failed. The root outcome records the user's
cancellation intent and remains `canceled` even when one or more child provider
cancellations could not be confirmed. `interrupted` on those child turns records
the weaker provider fact without pretending that cancellation succeeded.
Provider adapters additionally keep an execution-local cancellation boundary
for the canceled root turn. It is routing state, not a second durable business
model, and a later explicit canonical root turn clears it. While active:
- a server-initiated root provider turn is interrupted instead of adopted;
- a newly revealed native child thread is interrupted without allocating or
emitting a canonical child session/turn;
- output from the canceled root execution is dropped, while late output for an
already durable child may remain historical child progress unless that exact
child turn is itself a closed cancellation target;
- the matching root provider terminal is still normalized so persistence can
close the provider lifecycle projection.
## Provider Adapter Contract
Each provider adapter normalizes native child-agent behavior into the existing
session, turn, message, call, and interaction events. Child creation is one
atomic session-and-initial-turn state transition. Progress, waiting, terminal,
and name changes then use the same event and state vocabulary as a root session
and turn. There is no parallel `subagent_*` lifecycle or status projection.
Each normalized event must carry:
- child `agentSessionId` and child `turnId` as the canonical event owner
- parent `agentSessionId`
- parent `turnId`
- parent tool call id when known
- root `agentSessionId` and root `turnId`
- provider-specific handles and aliases
- optional transcript payload or interaction payload
The adapter also needs command hooks:
- cancel a child session/turn by normalized identity
- cancel all nested child sessions for a root turn
- guide the root turn while child sessions are active, when supported
Provider-specific event routing stays inside the adapter. AgentGUI and
workspace engine projections consume only normalized session, turn, parent,
root, message, and interaction contracts.
## Codex App-Server Mapping
Native concepts:
- Root conversation: app-server `threadId` equal to
`workspace_agent_sessions.provider_session_id`.
- Child agent: child app-server `threadId`.
- Spawn edge: either `collabAgentToolCall` with `tool=spawnAgent` and
`receiverThreadIds`, or `subAgentActivity` with `kind=started`,
`id=<parent tool call id>`, and `agentThreadId=<child thread id>`.
- Control tools: `Wait` / `CloseAgent` style `collabAgentToolCall` entries.
Mapping:
- Either spawn representation creates the normalized parent delegation call,
one subordinate `WorkspaceAgentSession` per child thread id, and a submitted
child turn owned by that session. If app-server reports the same edge more
than once, registration and normalized events remain idempotent.
- A `subAgentActivity` `started` item means the provider accepted the spawn, so
its normalized parent delegation call is completed. The child thread's own
events remain authoritative for the child turn lifecycle.
- `threadId != root provider_session_id` events are child events only after
the child thread has been registered from a spawn edge.
- App-server server requests use the same `threadId` ownership rule as
notifications. An approval or user-input request emitted on a registered
child thread is persisted against that child session and its canonical child
turn; the provider `turnId` remains transport metadata and does not replace
the canonical turn id. Requested, answered, superseded, and provider-resolved
events all retain the same child owner and root/parent relations.
- A server request for an unknown non-root thread is rejected instead of being
attributed to the root. Provider `serverRequest/resolved` notifications for
registered children must reach the shared pending-request registry through
the child target; they must not be discarded by child-notification filters.
- Unknown foreign-thread events must not update the root turn. They may be
counted as early drops and reconciled if a spawn edge later registers that
thread.
- Child session/turn parent and root fields are the only ownership contract. Remove
`ownerThreadId` / `ownerCallId` projection paths; do not keep a parallel
ownership structure.
- Child `turn/completed` maps to the corresponding child turn terminal. Root
provider `turn/completed` maps to `root_provider_turn_completed`.
`services/tuttid` applies the same root/child turn completion rule used for
every provider; the Codex adapter does not hold or settle the root turn
itself.
- Cancel interrupts the root thread and every nested child thread. The adapter
interrupts only child threads named by the daemon-supplied target set; it
does not independently enumerate linked children.
- After root cancellation, a later `turn/started` on the root thread is not an
ordinary unowned turn: interrupt it immediately. A child thread first
revealed by a late `spawnAgent` item is also interrupted immediately, but is
not registered in the canonical child-thread map and emits no child
session/turn creation events.
Known edge from current logs:
- A child thread can emit name/lifecycle events before `receiverThreadIds` is
registered. The adapter must avoid starting a second root `WorkspaceAgentTurn` for the
child provider turn id; early child markers should reconcile into the
eventual child session/turn or remain diagnostics.
Guidance mapping:
- If the root provider turn is active, use app-server `turn/steer`.
- If no root provider turn is active but the root `WorkspaceAgentTurn` remains
active because child turns are running, use app-server `turn/start` on the
root thread and attach its events to the existing root `WorkspaceAgentTurn`.
- Do not create another root `WorkspaceAgentTurn` for either path.
## Claude Code SDK Mapping
The Claude continuation produced after a background child notification belongs
to the same canonical root turn. This is a product ownership rule, not an
assumption about native SDK event order. A live parallel-child run recorded the
final child terminal at `23:24:06.438`, its SDK task notification at
`23:24:06.442`, canonical root settlement at `23:24:06.447`, composer unlock at
`23:24:06.538`, and the root assistant continuation at `23:24:09.036`. The
sidecar-created synthetic provider turn did not start until `23:24:09.039`.
Therefore the adapter cannot wait for the root assistant message before
protecting the canonical root from settlement.
Native concepts:
- The root turn streams through the SDK session.
- Child agent work is represented by `Task` tool use, `parent_tool_use_id`,
`task_id`, `agentId`, `TaskCreated`, `TaskCompleted`, `task_progress`, and
`task_notification` variants.
- The root SDK turn may appear terminal while child agents are still
running.
Mapping:
- A parent `Task` tool use creates a subordinate `WorkspaceAgentSession` and a
submitted child turn, keyed initially by the parent tool-use id.
- The parent `Task` / `Agent` tool call remains owned by the session and turn
that launched the child. Its started, updated, and terminal events must stay
on that same parent turn even after the child session has been registered.
Only tools and messages whose explicit `parent_tool_use_id` points into the
child are child-turn events. For nested delegation, that explicit marker
makes the launching child session/turn the direct parent.
- The child session and submitted child turn are emitted from the earliest
parent `Task` tool-use event, before `task_started`, `TaskCreated`, or the root
SDK `turn_completed` may arrive.
- Claude may reveal a generic tool name on the earliest event and provide the
real description, prompt, or agent identity only on a later tool/task event.
The adapter merges those later fields into the existing child session and
emits an ordinary child session title update when the description becomes
more specific. It must not create another child or move the parent tool
completion into the child turn.
- A foreground `Task` may settle its child turn from the terminal parent tool
result. An async/background `Task` does not settle from the launch tool
result; it waits for the matching task lifecycle terminal.
- SDK task-system work that has no subagent identity — most importantly a
`run_in_background` Bash shell — is tracked separately from delegated child
work. The successful root result completes the launch tool call even when
`task_started` is delayed; an earlier `task_started` may complete it with the
provider task id. The detached process remains independently stoppable, but
its lifetime does not create a child session, gate the root turn, count in
the final-child continuation gate, or synthesize follow-up model work.
- Task-system process events frequently arrive after the launching root
provider turn reached its terminal. The sidecar may retain their
`tool_use_id` / `task_id` correlation for stop routing, but must not project
them as late child activity.
- A terminal `task_notification` for a task the current sidecar instance never
saw start may recreate delegated state only when the message carries explicit
subagent identity. An unclaimed generic task-system notice is not enough to
invent a child session.
- `parent_tool_use_id` is the canonical child scope marker for nested messages.
- `taskId` and `agentId` are aliases. They must bind back to the existing child
session/turn, never to "the only running task" when that would cross-bind
concurrent children.
- Claude permission callbacks keep the provider turn id that owns the SDK
callback even after `agentId` scopes the interaction to a canonical child
session/turn. `submit_interactive` and `interactive_disposition` use that
provider turn id for sidecar correlation; all durable and user-facing state
continues to use the canonical child tuple.
- Scoping a Claude event to a child is atomic: the event's agent session id,
child provider session id, session kind, root/parent relation, and turn id
must all identify that child. A shared root runtime may deliver the event,
but it must not emit a root-owned event decorated with child relation fields.
This applies equally to provider-pushed resolution events and locally
generated approval acknowledgment events.
- Child assistant messages are progress, not completion. Completion comes from
child result, task notification, or SDK completion hooks.
- Child session/turn state is the only background-agent state. Remove
`runtimeContext.backgroundAgents`; do not keep a parallel state projection.
- The root `WorkspaceAgentTurn` stays active while any nested child turn remains
active, even if the SDK root provider turn produced a terminal event.
- The Claude SDK root `turn_completed` maps to
`root_provider_turn_completed`, never directly to canonical root
`turn.completed`.
- Claude Goal completion is a provider-native session metadata update, not a
root-turn completion input. The SDK must be allowed to finish and return
normally when it emits `turn_completed`, even when the canonical root remains
`waiting` for a child.
- An adapter-generated Claude Goal command turn is internal control traffic,
not a root provider turn. Its `turn_started`, assistant/thinking
acknowledgement, and terminal event must not emit root-provider lifecycle
facts or affect the canonical root gate. Goal/session updates still flow, and
the terminal only releases the adapter's control-turn bookkeeping.
- A continuation caused by a child task notification is another root provider
turn attached to the same canonical root `WorkspaceAgentTurn`. Its
`synthetic-*` id remains a provider id; the adapter must not promote it into a
`WorkspaceAgentTurn` id.
- When the final active async child is about to become terminal and no root
provider turn is active, the Claude sidecar opens a synthetic provider turn
before emitting the child terminal. This is a provider-specific ordering
guarantee derived from the SDK task notification boundary. The generic
`services/tuttid` root gate then remains unchanged: it observes a running
provider turn when the last child becomes terminal and keeps the canonical
root active.
- A `stopped` task terminal is the exception: a user-initiated stop needs no
model follow-up, so the sidecar does not open the synthetic provider turn for
it. The child settles, and the root gate resolves from the ordinary
provider/child state without a reserved continuation or its timeout.
- Opening at the notification boundary reserves the provider lifecycle; it
does not claim that Claude has already emitted assistant output. The sidecar
waits at most 30 seconds for the first root stream or assistant event. Once
root output begins, the timeout is disarmed and the provider turn follows its
normal SDK lifecycle.
- If no root continuation begins within 30 seconds, the sidecar completes the
reserved synthetic provider turn with a timeout stop reason and interrupts
that pending SDK continuation. `services/tuttid` may then settle the
canonical root from the ordinary provider/child gate. A later assistant,
stream, result, or synthetic start from that timed-out continuation is stale
and must be dropped by the Claude adapter; it cannot reopen the settled root
or append output to a later canonical turn.
- A nested `Task` tool call belongs to its parent child session/turn. The new
nested child session keeps the original root relation and records that parent
child session/turn as its direct creator.
- Cancel stops the SDK root provider turn and every known child task/agent that
the SDK can address.
- The synchronous adapter returning from `Exec` means only that the current SDK
provider turn ended. The runtime controller retains the canonical active root
turn until `services/tuttid` commits root settlement and reconciles it back.
- After the final child becomes terminal, `services/tuttid` applies the current
root provider outcome only when no notification-reserved synthetic provider
turn remains active. Claude Goal status is not re-evaluated as part of that
settlement.
- Guidance always targets the root session. If no SDK root provider turn is
active while child turns remain active, the sidecar may queue/fold in the
guidance and open a `synthetic-*` provider turn; that continuation is attached
to the existing canonical root turn.
- Guidance submitted during the notification wait remains part of the same
canonical root and the same reserved synthetic provider turn. It does not
create a new canonical turn or reset the 30-second hard wait boundary. If the
boundary expires first, the pending continuation, including that guidance,
is interrupted; a later user submission starts a new canonical root turn.
- Cancellation during the notification wait uses the existing root-query
interrupt. It cancels the reserved synthetic provider turn, disarms its
timeout, and follows the same durable root-plus-children cancellation
operation as any other point in the root lifecycle. No timeout completion or
late continuation may overwrite the canceled canonical outcome.
- Before sending that root-query interrupt, the Claude adapter records every
exact `(agentSessionId, turnId)` supplied by `services/tuttid` as a closed
projection boundary. Normal weak ordering remains supported: after an
ordinary root provider completion, an active child may still report its own
terminal. After targeted cancellation, however, late `stopped`,
`tool_failed`, or equivalent events for a closed child are cancellation
fallout and cannot emit a second child terminal or reclassify `canceled` as
`failed`.
- Sending cancel is not a terminal fact. The adapter closes local in-flight UI
streams, but root/child turns settle canceled only from provider confirmation;
unconfirmed child turns use the generic interrupted timeout.
## ACP Provider Mapping
ACP does not currently give one guaranteed child-agent shape across providers.
Treat ACP as capability-driven:
- The current standard ACP adapter does not opt in. It does not infer child
sessions from ordinary tool/progress updates and exposes no parallel
background-agent runtime projection. Cursor and OpenCode therefore remain
root-only until their ACP transports expose the required identity and
lifecycle facts; a tool named `Task`, `Agent`, or similar is not enough.
- Cursor Agent `2026.07.01-41b2de7` also emits a private, non-blocking
`cursor/task` extension containing `toolCallId`, `agentId`, task metadata,
and optional duration after Task activity. This is stronger identity
evidence than the ordinary Task card, but it does not supply one uniform
child lifecycle. For a foreground Task, the extension follows the completed
Task execution. For `run_in_background=true`, the Task result and extension
acknowledge launch within the root prompt while the detached child continues
running. Cursor records the later completion in its internal background-work
registry, but this ACP version does not bridge that terminal back through a
child event or a synthetic provider turn. Log only identifiers, field
presence, enum/model values, durations, and text lengths; never log task
prompt or description content.
- Until Cursor ACP exposes the detached child terminal, Tutti supports Cursor
Task execution only in the foreground at the canonical-model level. Cursor
Agent `2026.07.01-41b2de7` does not merge hooks from `--plugin-dir` into its
ACP hook executor, so Tutti cannot currently enforce that restriction before
launch without modifying user/project Cursor configuration. Runtimeprep keeps
a tested `preToolUse` background-Task guard dormant, but deliberately omits it
from the generated plugin manifest and runtime artifact. Do not claim that
background Task is blocked, do not write the guard into user/project config,
and do not invent a child terminal, keep the canonical root waiting, or
settle from a guessed timeout when a detached Task is observed.
- An ACP provider may create child sessions only when it supplies a stable child
id, the direct parent session/turn or parent tool-call id, and child turn
terminal events.
- The parent/child link must be available early enough to persist the child
session and submitted child turn before the root provider terminal is
applied. If the provider cannot guarantee that ordering, its adapter must
reconcile the explicit ids before emitting `root_provider_turn_completed`.
- Root provider terminal events map to `root_provider_turn_completed` and use
the same `services/tuttid` root/child completion rule as Codex and Claude.
Standard ACP adapters must not emit a canonical root `turn.completed`,
`turn.failed`, or `turn.canceled` directly from the `session/prompt` result.
- Turn-scoped ACP message, thought, tool, and permission events are accepted
only while their owning `session/prompt` call is active. A late notification
after the prompt result must not be attached to a recently settled root turn,
and the adapter must not fabricate a synthetic turn to make it persistable.
- If the provider only emits ordinary tool calls, display text, or progress
without stable parent/child ids, treat them as ordinary tool calls, not child
sessions.
- If the provider cannot confirm child cancellation, the generic cancellation
timeout marks those child turns `interrupted` as already defined. Lack of a
child cancel API does not justify a second completion model.
- Root-turn guidance support is declared by the provider. Direct user guidance
to one child remains unsupported.
- Standard ACP cancellation is root-only. A failed `session/cancel` transport
write is an operation failure, not provider confirmation; it must propagate
to the durable cancel workflow instead of being reported as success.
- The same rule applies to automatic permission decisions: a failed ACP
response write must be surfaced as an operation failure, never treated as a
resolved approval while the provider is still waiting.
ACP adapters should not invent subagent identity from display text, tool title,
or message order alone.
The existing standard ACP `backgroundAgents` map and runtime-context projection
are removed. ACP providers that meet the rules above emit child session/turn
events directly; providers that do not meet them emit no child-session state.
## UI Projection
AgentGUI should render subordinate sessions under the parent delegation tool
card.
- Root transcript rows remain root-only.
- Child-session rows are lanes or nested progress sections under the
spawn/delegate card. Nested child sessions follow recorded parent/root fields
rather than being flattened into the root transcript.
- Spawn tool success means delegation was accepted, not that the child
completed.
- Child terminal state comes from its canonical child turn, not from the parent
tool call status.
- Display-only transcript rows must not become approval or prompt authority.
Pending interactions remain daemon-owned canonical interaction entities on
the exact child session and turn.
- The session detail reconcile loads the root plus every nested child session
into the same workspace engine and loads messages through each session's
existing message endpoint. It does not create a second child-session store.
- An interaction action routes by the exact child
`(agentSessionId, turnId, requestId)` tuple. The controller uses the recorded
root session only to find the shared live provider runtime.
- Historical rows that only contain `ownerThreadId`, `ownerCallId`, or
`runtimeContext.backgroundAgents` are not reconstructed into the new model.
Do not add dual reads, heuristic backfill, or parallel projection paths for
those shapes.
## Open Questions
- Which ACP providers expose enough child-agent identity and cancellation
handles to opt into this abstraction?
## Implementation Shape
Suggested order:
1. Extend the session contract with root/child kind and durable parent/root
fields.
2. Make root turn settlement aggregate all nested child turns.
3. Adapt Codex app-server child-thread routing to create/update subordinate
sessions and their turns.
4. Adapt Claude SDK task/background-agent routing to create/update subordinate
sessions and their turns.
5. Project child sessions into AgentGUI lanes and remove the superseded
`backgroundAgents`, `ownerThreadId`, and `ownerCallId` paths.
6. Add cancel and guide command paths that operate on the active root turn plus
nested child sessions.
Validation should include concurrent children, early child events before spawn
registration, root terminal before child terminal, cancellation with late child
events, child creation racing a prepared root cancellation, a provider terminal
arriving after canonical cancellation, unowned provider turns after cancel,
child approvals, and user guidance while children are running.
@@ -0,0 +1,136 @@
# 模型接入與 Tutti Mode:實測回饋與修復 Goal
- 日期:2026-07-17
- 分支:`feat/unified-model-access-plans`(已含 7 個提交,最新 `e2ceb88cc`)
- 上游需求:[《统一模型接入、任务编排与预算控制 PRD》rev 294](https://ccn53rwonxso.feishu.cn/docx/R81AdPRwZow50MxwOFfcKQCNnZb)
- 狀態:**定稿(2026-07-17)** —— 四個開放問題已由需求方拍板,本文件為本輪修復的驗收基準
- 來源:需求方在 dev 環境的完整實測回饋(含截圖:Workspace Agent 首頁 composer 卡在「正在加载」)
## 背景
第一期已交付:Model Plan CRUD 與四階段偵測、Workspace Agent 配置、AutomationRule
(consult/fork/delegate/handoff)、`TuttiModeActivation` + `/tutti` badge、
Tutti Mode Plan 兩段式 checkpoint(configuration → task graph)、Issue 物化。
本輪實測發現大量交互與語義偏差,需要一輪系統性收斂。
**工作樹現存未提交改動(in-flight,屬第①簇的一部分)**:
模型編輯器由自由文字輸入改為 Combobox 候選目錄選型
(新增 `packages/ui/system/src/components/combobox/`、
`workspaceModelPlanCandidates.ts`、`workspaceModelPlanDraftModels.ts`),
且 daemon 偵測在草稿未選模型時自動以第一個發現的候選做連通性測試
(`services/tuttid/service/modelplan/detection.go`)。此批改動應先驗收收編為基線。
---
## 回饋清單
### ① 模型方案配置流程(順序與交互重排)
目標流程:**憑證 → 拉取模型 → 模型列表(行內添加/行內設預設)→ 檢測(最後一步)**
| # | 回饋 | 目標行為 |
| --- | -------------------------------------------- | ------------------------------------------------------------ |
| 1-1 | 配完 Base URL + API Key 後缺「拉取模型」按鈕 | 憑證欄位下方提供顯式「拉取模型」動作,成功後展示可選模型目錄 |
| 1-2 | 「添加模型」在左下方,位置錯 | 添加入口放在模型列表內(列表尾部行內添加),移除左下方按鈕 |
| 1-3 | 不應單獨選「預設模型」 | 每個模型行內提供「選為預設」,移除獨立的預設模型選擇器 |
| 1-4 | 檔位/幣種/輸入/輸出/快取讀寫定價拿不到 | 全部隱藏,模型行只顯示名稱(後端欄位保留與否見 Q1) |
| 1-5 | 「檢測連接」應是最後一步 | 檢測動作移到編輯器底部,作為保存前的最終驗證步驟 |
主要落點:`apps/desktop/.../ui/WorkspaceModelPlanEditor.tsx`、
`workspaceModelPlansController.ts`、`services/tuttid/service/modelplan/`。
### ② Agent 配置簡化(Workspace Agent 編輯器)
保留:**名稱、Agent Runtime(原 Harness 改名)、模型方案 + 預設模型、描述**。
| # | 回饋 | 處理 |
| --- | ----------------------------------- | -------------------------------------------------- |
| 2-1 | 智能生成配置沒用 | 移除(UI 入口 + 生成鏈路,範圍見 Q1) |
| 2-2 | 名稱 | 保留 |
| 2-3 | 「Harness」命名太抽象 | 改為「Agent Runtime」(i18n 雙語) |
| 2-4 | 模型方案 OK,但應可用訂閱等其他方案 | 確認方案選擇器覆蓋 subscription/coding plan 類方案 |
| 2-5 | 模型故障轉移鏈不完善 | 先移除 |
| 2-6 | 「用途」欄位 | 移除,只留一個基礎「描述」欄位 |
| 2-7 | 兼容能力(Skills/插件/連接器選擇) | 完全移除,回到自動同步全部 |
| 2-8 | 「允許用於新對話」開關看不懂 | 移除 |
| 2-9 | 高級能力與 ID 與權限 | 移除 |
主要落點:`apps/desktop/.../ui/WorkspaceAgentEditor.tsx`、
`workspaceAgentsController.ts`、`services/tuttid/service/workspaceagent/`。
### ③ 自動化規則重構(觸發 + 單一動作語義)
- 觸發條件保留兩個:**任務完成時**、**任務中斷/失敗時**。
- 動作徹底簡化:移除「諮詢 / 模型分叉 / 委派 / 移交」四選一。
**唯一行為 = 自動創建一個新會話,把事件來源會話 @ 進去,
目標 Agent 依據給定提示詞直接執行。**
| # | 回饋 | 目標行為 |
| --- | --------------------------------------- | ------------------------------------------------------------------------------- |
| 3-1 | 沒配 Workspace Agent 就選不了目標 Agent | 目標 Agent 必須始終可選內建預設 Agent(Codex / Claude Code 等 harness targets) |
| 3-2 | 權限模式選不了 | 修復;權限模式可選 |
| 3-3 | 權限模式與允許的工具應跟隨目標 Agent | 兩者的選項目錄由所選目標 Agent 的能力目錄動態決定 |
主要落點:`WorkspaceAutomationRuleEditor.tsx`、
`services/tuttid/service/automationrule/`(daemon_executor、四動作分支)、
`services/tuttid/service/collabrun/`(處置見 Q2)。
### ④ Tutti Mode 交互
| # | 回饋 | 目標行為 |
| --- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| 4-1 | Budget 特殊優化 | 點擊 Tutti Budget → Popup:內含**編排強度滑桿(最低→最高)** + **取消按鈕**(點外部不關閉)。強度值持久化,後續規劃 Agent 執行時可讀取 |
| 4-2 | 計畫預算 Token 上限永遠是 0 | 直接移除該功能 |
| 4-3 | 首次 Trigger 不應出現編排強度;現在分兩次(先計畫、再任務),修改也不生效 | **單次流程**:Agent 一次產出「計畫敘述 + 拆解好的任務列表」,一個 review 面板;修改請求真實生效 |
| 4-4 | 最終 Graph Plan 的任務拆分列表沒接右側面板 | 任務列表與右側面板打通:每個任務可手動選 **Agent、推理強度、權限、模型**(對齊 PRD 任務級指派) |
主要落點:`services/tuttid/service/tuttimodeplan/`(兩段 checkpoint 狀態機)、
`packages/agent/gui/workspaceWorkflow/tuttiModePlan/`、
`TuttiModeActivation`(強度掛載點,見 Q3)、Issue Manager 右側面板整合。
### ⑤ Bugs(阻塞測試,最高優先)
| # | 現象 | 初步判斷 |
| --- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| 5-1 | 配置了 Workspace Agent(自定義模型方案 Agent)後,composer 的權限名單與模型名單永遠「正在加载」(見截圖),但消息可發出 | composer options 對 workspace-agent target 的投影/載入鏈路卡死;待定位(可能與 model plan binding 或 options 事件有關) |
| 5-2 | 配置 Workspace Agent 後,原本內建的正常 Agent 不再展示 | Agent 目錄聚合把兩類 target 做成互斥;應共存 |
---
## 已拍板決策(2026-07-17)
- **D1 — 移除深度:分級處理。**
「先去掉/還不完善」類 → 僅拆 UI 與入口,後端契約保留 dormant:
故障轉移鏈(2-5)、兼容能力(2-7)、計畫預算 Token 上限(4-2)、定價/檔位欄位(1-4)。
「根本沒用」類 → 全鏈路刪除(OpenAPI、daemon、存儲、測試一併清):
智能生成(2-1)、用途欄位(2-6)、允許用於新對話(2-8)、高級能力/ID/權限(2-9)。
- **D2 — 自動化動作退役(範圍限自動化)。**
自動化規則中不再出現 consult/fork/delegate/handoff 四動作選項與其執行分支;
自動化唯一行為 = 新建目標會話 + @ 事件來源會話 + 規則提示詞。
**保留**:CollaborationRun 底層基礎設施與 `@模型` 諮詢功能——所有透過其他管道
調用模型的能力技術上都保留,只是不在自動化設定中暴露。
- **D3 — Tutti Mode:單一 checkpoint。**
configuration checkpoint 取消;編排強度由 Budget popup(4-1)在觸發前設定並持久化;
Agent 一次產出「計畫敘述 + 任務圖」單一 revision;只留一個可編輯 review 面板
(含 4-4 的任務級 Agent/模型/權限/推理強度指派),「請求修改」產生新 revision。
- **D4 — DoD:GUI 實測必須。**
每簇修完須在 dev 環境以 CDP 驅動真實走查並留截圖證據,
34 條驗收腳本逐條通過;自動化測試與對抗式 review 是第二道防線,不是完成定義。
## Goal(定稿)
> 以本文件為驗收基準,修復 ⑤ 兩個阻塞 bug,並完成 ①–④ 四簇交互/語義收斂,
> 使模型接入 → Agent 配置 → 自動化 → Tutti Mode 全鏈路可被真實用戶操作走通;
> 每簇交付需通過對抗式 code review 與 GUI 實測驗證,全量門禁(check:changed)綠。
執行結構(波次按共享熱點隔離:desktop locale 重的簇不並行):
- **Wave 1(並行)**:A = review 收編 in-flight 模型編輯器改動;B = 診斷 ⑤ 兩個 bug(只讀定位根因)。
- **Wave 2(並行)**:⑤ bug 修復落地;① 模型配置流程重排收尾(同在 desktop settings,若檔案重疊則串行)。
- **Wave 3(並行)**:② Agent 配置簡化(desktop locale 熱點)+ ④ Tutti Mode(agent-gui locale 熱點,互不重疊)。
- **Wave 4**:③ 自動化重構(與 ② 共享 desktop locale、與 ④ 共享 agent-gui,故單獨一波;含 D2 退役)。
- **每簇 DoD**:實現 → 對抗式 review agent → CDP GUI 實測 + 截圖 → 主線彙總。
- **收尾**:`check:changed`(必要時 `check:full`)、架構文檔同步(model-access-plans /
workspace-agents-and-automation / workspace-workflows)、驗收腳本全過。
- **提交策略**:每個 Wave 驗收通過即可 commit 並 push 到本功能分支;**不合入 main**,
最終等人工驗收。
+783
View File
@@ -0,0 +1,783 @@
# 開放疑問記錄:模型接入與 Tutti Mode 實測回饋修復
執行 Goal 期間遇到的 spec 未覆蓋語義分歧與假設決策,逐條記錄於此;
全部任務完成後由需求方回顧。格式:問題 → 所做假設 → 影響範圍 → 建議。
對應驗收基準:`docs/specs/2026-07-17-model-access-ux-feedback-and-goal.md`
---
(各 Wave 執行時追加)
## 主線 GUI 走查觀察(Wave 2,P3 級,未修)
### W2-GUI-1 Combobox 打開時按 Escape 會連設定彈窗一起關閉
- 問題:模型選型 combobox 的 popover 打開時按 Escape,預期只關 popover,實際整個設定
彈窗一起關閉(Escape 事件未被 popover 攔截)。
- 影響:鍵盤用戶誤關設定頁,未保存的草稿被丟棄。
- 建議:combobox/popover 在打開態消費 Escape(stopPropagation),後續批次處理。
### W2-GUI-2 歷史殘留 target 顯示「即將上線 / coming soon」佔位
- 問題:主視圖恢復到一個已不存在的 `codex:standalone:<workspaceId>` target 時,
顯示「即將上線,暫未開放」佔位——文案誤導(它不是未上線,而是殘留身份)。
- 影響:視覺誤導;不卡死、可切走,數據無損。
- 建議:對解析不到的殘留 target 給「該 Agent 已不存在」語義而非 coming soon。
### W2-GUI-3 同一方案重開編輯器時候選目錄與計數殘留
- 問題:編輯器關閉再重開同一方案,上次拉取的候選目錄與「已拉取 N 個」計數仍在
(store 級 draft 狀態未隨編輯器關閉清空)。跨方案/新草稿已驗證不洩漏。
- 影響:同端點自身緩存,語義無害;計數文案「已拉取」略有歧義。
- 建議:可在 beginEditPlan/beginDraft 時一律清空 draftDiscoveredModels,或保持現狀。
## Wave 2-①(模型方案配置流程重排)
### W2①-1 「拉取模型」復用檢測鏈,但不寫入檢測結果
- 問題:spec 1-1 未規定「拉取模型」是否等同一次連接檢測。daemon 的檢測鏈
(detect)會順帶執行 inference(草稿未選模型時以第一個發現的候選做臨時推理)。
- 假設:`fetchDraftModels` 復用 `detectModelPlan` 調用,但**只取
`discoveredModels` 進候選目錄,不寫入 `draftDetection`**。保存門檻仍然只認
底部「檢測連接」的結果,因為最終檢測應驗證用戶所選的預設模型,而非拉取時
的臨時候選。
- 影響:拉取成功後仍需在最後點一次「檢測連接」才能保存(多一次顯式步驟);
每次拉取會多付出一次臨時推理調用。
- 建議:若需求方認為「拉取成功即可視作檢測通過」,只需讓 fetch 同步寫入
draftDetection;若在意拉取的推理成本,可在 daemon 增加輕量 discovery-only
入口(本輪按約束未動 Go/OpenAPI)。
### W2①-2 「模型行只顯示名稱」按字面執行,capabilities 提示行一併移除
- 問題:1-4 針對檔位/定價,但模型行原本還有一行 capabilities 展示。
- 假設:按「模型行只顯示名稱(含 combobox 選型)」字面理解,capabilities
提示行一併移除;capabilities/pricing/tier 數據仍保留在草稿與保存 payload
中(D1 dormant,discovery 帶回的元數據不丟)。
- 影響:UI 不再展示模型能力標籤。
- 建議:如需保留能力提示,可恢復為 combobox 選項的描述副行,不必回到行內展示。
### W2①-3 拉取失敗的判定啟發式
- 問題:spec 未定義「拉取失敗」的邊界(請求成功但認證失敗、或返回空目錄)。
- 假設:請求異常,或 `model_discovery` 階段未通過(passed/skipped 之外)且返回
0 個模型時,顯示 `fetchModelsFailed`;discovery 通過但 0 模型視為成功的空結果。
- 影響:認證/網絡失敗會顯式報錯,而不是靜默給出空候選目錄。
- 建議:後續可把檢測鏈的具體失敗階段透出到拉取反饋文案(現為單一失敗文案)。
- 補充(review 回派後):拉取成功但 0 模型現在顯示中性提示
`fetchModelsEmpty`(「接口未返回可选模型,你仍可手动输入模型 ID。」),
避免按鈕看似無響應。
- 修正(GUI 實測 P1 回派後):分類規則收緊為**只有 discovery 階段明確
`passed` 且 0 模型才算 empty**;`failed`/`skipped`/缺失一律 `fetchModelsFailed`。
原規則把 skipped 當成功,導致 network 失敗(discovery 被跳過)誤顯示為
「空成功」。已對照 daemon 兩條檢測鏈驗證:端點鏈(detection.go)與
native-login 鏈(native_subscription_detection.go)在 runtime 不可用/未登錄/
網絡失敗時 discovery 均為 skipped → 現在正確歸為失敗。已知的外緣情況:
「端點確實沒有模型目錄(全部 404/405)且草稿已配置手動模型」時 discovery
也是 skipped,會顯示失敗文案而非中性文案——語義上偏嚴但不誤導(該端點
確實拉不到目錄);如需區分可依賴 discovery.detail 或 daemon 增加顯式
`no_catalog` 狀態,留待主線裁決。失敗階段透出到文案(网络不可达 vs 认证
失败)因 feedback 結構為 kind-only,擴展會波及類型與測試面,按回派許可
維持單一失敗文案。
### W2①-4 端點方案保存門檻要求 ≥1 個模型(review 回派決策)
- 問題:daemon 允許「草稿未選模型」時以第一個發現的候選做臨時推理驗證,
導致檢測可通過但方案可被保存為 0 模型、無默認模型的狀態;spec 未明文
規定保存時的最小模型數。
- 決策(對抗式 review 拍板):**端點(endpoint-backed,需 Base URL + API
Key)方案在保存時要求 ≥1 個 normalized model**;native-login(官方訂閱)
方案維持現狀——新建仍要求選模型,已保存方案可在未動模型時直接重存
(模型可來自 provider 目錄)。落點:`workspaceModelPlanDraftRules.ts`
的 `hasRequiredWorkspaceModelPlanDraftFields`。
- 影響:`requiredFieldsMissing` 文案(「請填寫名稱和模型列表…」)與規則自洽;
0 模型端點草稿即使檢測通過也無法保存(新增回歸測試覆蓋)。
- 建議:若未來允許「純目錄方案」(模型完全運行時發現),需同步放寬此門檻
並定義無默認模型時的運行時語義。
### W2①-5 custom 輸入命中 sibling 已選 id 時為靜默 no-op(僅記錄)
- 現狀:候選目錄已排除 sibling 已選項,僅手動輸入完全相同 id 時觸發守衛,
行為是不替換、無提示。review 裁定留給主線 GUI 走查決定是否需要顯式反饋。
## Wave 2-⑤(bug 5-1 / 5-2 修復)
### W2⑤-1 Workspace Agent 刪除語義選 (b):允許刪除 + GUI 優雅降級
- 問題:spec ⑤ 第 3 層給出兩選一:(a) 比照 `modelplan ErrPlanReferenced`
阻擋刪除仍被會話引用的 Agent;(b) 允許刪除並保證 stale 引用優雅降級。
- 決策:選 **(b)**。理由:會話記錄天然比 Agent 配置長壽,(a) 會讓「用過的
Agent 永遠刪不掉」除非引入「活躍會話」子集判定與級聯提示——這需要新錯誤
碼/OpenAPI 契約變更(本輪禁區);且第 1/2 層修復後 stale 引用已從「永久
正在加载」降級為「可重試的顯式錯誤態」(4xx 不自動重試)。
- 落點:daemon 為 workspace agent CRUD 補了 slog 審計事件
(`workspace_agent.created/updated/deleted`,services/tuttid/service/
workspaceagent/service.go),使後續 `agent_target_id.dropped` 攝入告警
可回溯到具體刪除動作。
- 建議:若需求方仍希望阻擋刪除,建議只計「存在未完成 turn 的活躍會話」,
並沿用 `model_plan_referenced` 的錯誤形狀新增 `workspace_agent_referenced`。
### W2⑤-2 composer options 重試策略下沉 engine(次數/退避的取值)
- 問題:spec 要求「非 4xx 失敗加有限退避重試」,未定次數與延遲,也未定
重試邏輯的落點。
- 假設:重試作為 reducer 轉移下沉到 activity-core engine(效仿
effectExecutor 註釋「Retrying…are reducer transitions」),而非在
useAgentGUIComposerOptionsSync 增加 effect(degradation ratchet 禁增)。
取值:最多 2 次自動重試,退避 2s / 8s;4xx(含 400/404 stale 引用)不
重試;重試期間狀態保持 loading,預算用盡才落 error 態(帶手動重試入口)。
為此 EngineScheduleExpiryCommand 增加可選 `delayMs`(commandResult 不帶
時間戳,reducer 無牆鐘錨點),EngineCommandResultIntent 增加可選
`errorStatusCode`(由 effectExecutor 從 TuttidProtocolError.statusCode 提取)。
- 影響:所有經 engine 的 composerOptions/load 統一獲得有限重試;手動重試
(錯誤態點擊)每次授予全新重試預算。
- 建議:主線 review 關注 delayMs 對 engine 內部命令契約的擴展是否可接受。
### W2⑤-3 create 模型綁定 fail-safe 閘的取捨(Bug A 回派)
- 問題:跨方案裸模型 id(如 x-ai/grok-4.5)經 lastActiveModel 記憶/持久化
defaults 洩漏到不相容 provider 的 create(daemon 400)。spec 未定 fail-safe
的具體判定規則。
- 假設/決策:
1. 模型身份統一為 {model, modelPlanId} 成對:記憶(lastActiveModelByProvider、
同 provider 活躍會話繼承)全部改存/回填成對;帶 modelPlanId 的 create 由
daemon `applyRequestedModelPlan` + `resolveProvidedModelPlan` 全量校驗
(協議/enabled/model∈plan),**daemon 側無校驗缺口,未改 Go**。
2. create 最後一道閘 `enforceComposerModelBindingForCreate`:裸模型僅在
options 已載入且出現在 options.models 時放行;options 為 null(未載/失敗)
或模型不在名單 → 丟棄模型(daemon 用默認),寧可不帶不可帶錯。例外:
options 已載入但 models 名單為空(provider 不宣告目錄)沿用既有 sanitize
語義放行,由 daemon clamp。
3. **持久化 composer defaults 的 schema(OpenAPI 生成型別)沒有 modelPlanId
欄位且本輪禁改 OpenAPI**:方案模型一律不寫入 remembered defaults(bare id
斷源),native 模型照舊記憶。後續若要跨重啟記住方案模型,需擴展
DesktopAgentComposerDefaults 增加 modelPlanId(OpenAPI 變更,記待辦)。
4. 選 native 模型(patch 無 modelPlanId)時顯式清空 planId
(`pairedComposerSettingsPatch`),杜絕 stale plan + native model 混搭。
- 影響:同 provider 的裸 native 模型繼承在 options 未載入的窗口內會退回
daemon 默認(行為更保守);活躍會話中從方案模型切 native 模型現在會觸發
「新會話生效」提示(planId 顯式變化),語義更正確。
- 建議:主線 CDP 驗證「方案會話 → 回 codex home 首發」不再 400,以及顯式選
方案模型後 create payload 帶完整成對綁定。
### W2⑤-4 聚合模型菜單的降級與合併語義(Bug B 回派)
- 問題:codex home 聚合菜單缺 provider 原生模型;options 未載入時降級行為未定義。
- 假設/決策:
1. 非方案綁定 target:聚合方案模型**擴展而非替換**原生模型列表
(native 在前 + aggregate 在後;值空間不同無碰撞)。
2. 方案綁定 target(options.modelPlan 非空):維持 aggregate-only——其
「原生」名單本就是綁定方案的模型,合併會出現重複項。
3. options 未載入窗口:選「顯示 loading」而非「僅安全項可選」——model
支持度(modelConfigurable)在 options 到達前未知,此時展示任何可選列表
都是猜測;菜單保持 disabled + 「正在加载」(配合 W2⑤-2 的重試/錯誤態,
不會永久 loading)。
- 建議:若需求方希望 options 載入前即可選方案模型(純方案工作流),需先定義
「options 未載入時 supportsModel 的默認語義」,屬產品決策。
### W2⑤-5 覆核回派:兩個 P1 已修 + P2-1 記錄不修
- **P1-1(已修)方案會話切 native 模型死路**:staged draft 的 planId 恆為顯式
鍵(null = native),`composerModelPlanRequiresNewSession` 原對空 planId 直接
false ⇒ staged native 永不生效、session draft 永久殘留。修:謂詞改為「draft
含顯式 planId 鍵且與 active planId 不同(含 null ≠ 非空)⇒ 需開新會話」;
已核實 session-keyed drafts 僅 staging/restore 兩個寫入者(皆經
resolveEffectiveComposerSettings,planId 恆顯式),無誤傷面。
- **P1-2(已修)同方案切模型裸記 defaults / 跨方案混搭 pair**:持久化推導下沉
純函數 `sessionComposerSettingsPersistence`——remembered defaults 用「patch
planId 鍵優先、否則 session 當前 planId」作有效綁定判定(方案模型不裸記);
node defaults 的 model 變更恆同步寫入 modelPlanId 鍵(成對),杜絕與存量
另一方案 planId 拼出混搭 pair。
- **P2-1(記錄不修)**:daemon 對 plan-bound target 若不回填
`runtimeContext.modelPlan`,聚合菜單會把「原生名單(=方案模型)」與聚合
方案項重複展示(僅視覺重複,無正確性問題)。待主線確認 daemon 回填契約的
覆蓋面後決定是否按 (planId, model) 去重。
## Wave 3-②(Agent 配置簡化)
### W3②-1 「用途 → 描述」僅改 UI 文案,數據仍走 purpose 欄位(過渡態)
- 問題:2-6 要求把「用途」改為單一基礎「描述」欄位;D1 把用途欄位歸入
「根本沒用 → 全鏈路刪除」,但本波禁改 OpenAPI/契約。
- 假設:UI label/placeholder 改為「描述/Description」(i18n key 仍叫
`purposeLabel`,內部標識符不強改),數據暫存於 `WorkspaceAgent.purpose`;
Agent 卡片副標題繼續展示該欄位。
- 影響:契約與 UI 語義暫時錯位(purpose 承載描述文案)。
- 建議:契約清理波把 `purpose` 重命名/合併為 `description`(OpenAPI +
daemon + 存儲 + 遷移),或直接刪除並將描述併入 instructions 首段。
- **已解決(Wave 4-②)**:採重命名方案。`purpose` → `description` 全鏈路落地
(OpenAPI、daemon、存儲遷移 `workspace_agents_contract_cleanup_v1` 拷貝
存量數據、desktop 服務層與 i18n key `descriptionLabel`)。
### W3②-2 保存即歸位:故障轉移鏈/能力白名單/權限覆寫在下次保存時清空
- 問題:2-5/2-7/2-9 只拆 UI(D1 dormant 或延後全刪),但編輯器已無法表達
這些配置;既有 Agent 若帶白名單/fallback/權限覆寫,打開編輯再保存會發生什麼
spec 只對 2-7 明示(回到 auto 是預期)。
- 假設:三者統一「保存即歸位」——保存 payload 恆發
`modelFallbacks: []`、`capabilitiesExplicit: false, skills: [], tools: []`、
`permissions: []`(daemon 對 explicit=false 會將 skills/tools 存為 nil,
已有測試 TestPutCapabilitySelectionDistinguishesAutomaticAndExplicitNone
覆蓋)。不保存則存量數據原樣保留(dormant)。
- 影響:帶存量 fallback/權限覆寫的 Agent 在任何一次編輯保存後回到中性配置,
且無 UI 告知。
- 建議:若需求方希望「僅隱藏不清除」,改為 draft 透傳存量值即可(一行改
`workspaceAgentDraftToPutInput`);contract 清理波刪欄位時此分歧自然消失。
- **已解決(Wave 4-②)**:分歧消解。`permissions` 已全鏈路刪除(不再存在
「保存清空」問題);dormant 欄位(`modelFallbacks`、`capabilitiesExplicit`/
`skills`/`tools`)改為 draft 透傳——`WorkspaceAgentDraft.dormant` 承載存量值,
`workspaceAgentDraftToPutInput` 原樣回發,保存不再破壞 dormant 數據
(新增回歸測試覆蓋)。
### W3②-3 「允許用於新對話」開關移除後,存量停用 Agent 無 UI 復啟路徑
- 問題:2-8 移除開關;`enabled` 欄位仍在契約中且 daemon Resolve 對
disabled Agent 嚴格拒絕。
- 假設:新建 Agent 恆 `enabled: true`;編輯保存時透傳存量值(不強行翻轉)。
存量 `enabled: false` 的 Agent 卡片仍顯示「已停用」狀態點,但不再有任何
UI 能把它切回啟用。
- 影響:僅影響歷史上手動停用過的 Agent;可刪除重建繞過。
- 建議:契約清理波(D1 對 2-8 是全鏈路刪除)直接退役 enabled 欄位,
daemon 遷移將存量 false 歸一為 true;屆時 Resolve 的 ErrAgentDisabled
分支同步刪除。
- **已解決(Wave 4-②)**:按建議執行。`enabled` 從 OpenAPI/daemon 移除,
遷移把存量 false 歸一為 true(物理列保留),`ErrAgentDisabled` 與
Resolve/validateReferences 的 enabled 分支全部刪除;卡片「已停用」狀態點
與 `agents.enabled` i18n key 退役(綠點語義改為「Runtime 可用」,key 改名
`agents.ready`)。
### W3②-4 方案下拉對「無 modelPlanProtocol 的 Runtime」只保留當前選中項
- 現狀:provider 目錄中 `codex`/`tutti-agent`/`opencode` 聲明
`modelPlanProtocol=openai`,`claude-code` 聲明 `anthropic`;`cursor` 等
Runtime 的 protocol 仍為空 → 方案下拉只顯示「使用 Agent Runtime 默認模型」
與(若存量已綁)當前方案。這是 daemon `validateHarnessPlan` 的鏡像
(不支持的 provider 綁任何方案都會被拒),非過濾 bug。
- OpenCode 已落地原生 openai plan 注入(`OpenCodePreparer` +
`ModelPlanModelAddressing=provider_prefixed`);若未來 cursor 支持注入,
需在 provider catalog 聲明 `modelPlanProtocol` 並重新生成目錄,前端無需再改。
## Wave 3-④(Tutti Mode 交互:Budget popup / 單次流程 / 任務級指派)
### W3④-1 編排強度落點:activation revision + Turn 快照 + Host Context 注入
> 2026-07-28 更新:單一 `orchestrationIntensity` 已由獨立的 `effect` 與
> `speed` 取代;舊持久化值遷移為 `effect`,`speed` 預設 50。下列內容保留為
> 當時決策記錄,目前契約以架構文件為準。
- 問題:spec 4-1 允許「注入 Host Context 與/或 propose 時記到 proposal 上」兩種落點。
- 決策:持久化在 `TuttiModeActivation` 的 revision 上(`orchestrationIntensity`
0-100,預設 50),隨每次滑桿確認 append 新 revision;Turn 派發時凍結進
`TuttiModeTurnSnapshot`,由 Tutti Host Context 注入給規劃 Agent。**不**在
propose 時由 daemon 記到 proposal——快照在 Agent 開始規劃前就已注入,語義
最穩;propose 時已經拆解完成,再蓋章為時已晚。plan 文件 frontmatter 的
`orchestrationIntensity` 仍由 Agent 回填(面板展示用)。
- 附帶取捨:強度變更 revision 沿用 `source: slash_command`(state 不變仍為
active)。原因:v1 SQLite 表對 (state, source) 有 CHECK 約束,新增 source
枚舉需重建表;「activation 轉換來源」語義仍成立。如需精確審計來源,後續
可加 `intensity_update` source 並重建表。
### W3④-2 兩段式遷移:startup cancel(非 supersede)
- 決策:daemon 啟動時一次性掃描,把「當前 pending checkpoint 為
configuration_review 且 workflow 非終態」的 workflow 走正常 Decide 路徑
cancel(actor `tutti`,reason "configuration review retired by the
single-review flow")。理由:dev 環境需求方已確認可安全作廢;cancel 比
supersede 語義更誠實(沒有新 revision 取代它)。
- dormant 保留:`configuration_review`/`generate_task_graph` 枚舉、
`phase: configuration` 的解析(僅為讀舊 revision 檔);歷史「已接受的
configuration checkpoint」仍允許前進到 task_graph revision(不破壞在途
合法狀態);新的 configuration-phase propose/revise 一律拒絕。
- propose mutation replay 放寬:舊 propose mutation 記錄的是
configuration_review checkpoint,replay 校驗改為只認「sequence==1 的初始
revision」,避免舊 request-id 重放直接報錯。
### W3④-3 「請求修改」生效機制:daemon 派發 feedback turn(best-effort)
- 決策:reject(task_review)提交後,daemon 透過 `FeedbackDispatcher` 席位
(wiring 注入,內部走 agentservice.SendInput)向 source session 發起新
turn,內容含反饋原文 + `tutti plan revise` 指令;成功後記
`WorkflowTurnLink(relation=feedback)`。派發為 fire-and-forget:決策已
durable,派發失敗僅記 slog,Agent 仍可經 plan get/wait 觀察到 rejected。
- 已知邊界:source session 若正有 active turn,SendInput 會失敗(目前不走
guidance、不排隊)。面板不會顯示派發失敗。建議後續:失敗時降級為
guidance 或掛入 prompt queue,並把派發結果透出到 workflow operation。
### W3④-4 任務級 reasoning effort / permission mode:net-new 引入(非復原)
- 考古結論:git pickaxe 顯示 issues domain 從未有過任務級 reasoning 欄位;
「先前被移除」實際發生在 composer/session 層的 schema 搬移(48ad757ab)。
故本波是按用戶要求 net-new 引入,非還原舊欄位。
- 鏈路:plan 文件 task frontmatter(`reasoningEffort`/`permissionModeId`,
離散 effort 詞彙/權限模式 id)→ OpenAPI `TuttiModePlanTask` → review 面板
逐任務編輯 → accept 決策 `taskAssignments` overrides(checkpoint JSON 欄位
durable 記錄)→ ActionableItem 投影合併 → issues domain
`Task.ReasoningEffort/PermissionModeID`(SQLite v12 遷移)→ 任務啟動
`CreateSessionInput.ReasoningEffort/PermissionModeID`(顯式 effort 覆蓋
Issue 級 intensity 編譯;顯式 permission mode 覆蓋 target 預設)。
- 未做:issue-manager.v1.yaml REST 未暴露兩個新欄位(4-4 明示不改 Issue
Manager 本體);Issue Manager UI 看不到任務級 effort/permission。如需展示
或編輯,後續要擴 issue-manager spec + regen。
### W3④-5 指派 overrides 的持久化與驗證邊界
- 落點:overrides 記在被接受的 checkpoint 上(decision 同事務寫入
`task_assignments` JSON 欄位),而非偽造一個「用戶 revision」——revision
維持 Agent 產物的不可變語義,ActionableItem 仍是
「document + accepted checkpoint」的純投影,recovery/replay 天然一致。
- 語義:null 欄位 = 保留文件值;空字串 = 顯式清空。decide replay 攜帶的
late overrides 被忽略(原 durable overrides 為準)。GUI 端切換 Agent 時
自動把 plan/model/permission/effort 清空(目錄按 Agent 篩選,防不相容值
靜默滑過)。
- 驗證邊界:decide 僅驗 taskId 存在於當前 revision;agent/plan/model 相容
性沿用物化時 `validateIssueTaskAssignment`(協議 + 模型成員資格)與啟動
時 composer 驗證。permissionModeId/reasoningEffort 值不在 decide 或物化
時驗證(目錄是 runtime 概念),非法值啟動時按 provider 語義降級/報錯。
### W3④-6 4-2 移除範圍的字面取捨
- 面板:移除 Token 上限行與 auto/fixed 徽章,保留配額水位線展示;composer
的 plan-issue 預算 preset 移除 auto/fixed 勾選與 token 輸入,保留兩個強度
滑桿。後端 budget 契約(mode/tokenLimit)全部 dormant 保留(D1)。
### W3④-P0 失敗創建渲染死循環:路由/回滾競態(已修)與兩個上游殘留
- 崩潰鏈(CDP 復現 + 逐層定位):session create 失敗 → 樂觀選中的 sessionId
回滾(home)與路由 demotion(active→requested→selectConversation)在同一
commit 波內互相餵養,composer 以 ~12ms/次在 home↔dead session 間往返
(日誌可見 52 次 form 掛卸),直到 React "Maximum update depth exceeded"
卸載整棵樹(黑屏)。錯誤棧裡的 Radix SelectTrigger/compose-refs 只是第 51
次嵌套更新恰好落在重掛的 Select ref 上,非根因。
- 修復:兩道防線(先紅後綠 + 真機復現驗證)。(1) routing 對「最新 activation
為 failed create」的 id 拒絕 demote/re-adopt(requested 直接落 home);
(2) 失敗回滾的抑制 ref 只被「另一個非空的外部選擇」解除,自身的空回聲不再
提前解除(否則殘留的失敗 id 持久化回聲會被重新採納)。
- 上游殘留(本波定位、未修,屬模型綁定/composer options 簇):
(a) create 失敗的直接原因是 composer 把裸模型 `x-ai/grok-4.5` 發給 codex
create——`lastActiveModelByProvider` 跨會話繼承了「經方案跑的模型」的裸
id,而 composer options 為 null(加載失敗/未加載)時
`sanitizeComposerSettingsForOptions` 原樣放行;顯式在聚合模型菜單選
「中转接入点」方案模型後 create 仍缺 modelPlanId(綁定丟失,二次復現同錯)。
(b) codex home 的聚合模型菜單在 options 未載入時只展示方案模型,無任何
provider 原生模型可選。兩者建議回派給 ⑤/模型綁定 owner。
## Wave 4-③(自動化規則重構:單一動作語義 + 目標/權限/工具目錄)
### W4③-1 action 欄位選「移除」:OpenAPI + biz 全退役,v2 遷移歸一存量
- 問題:D2 允許「移除或 dormant」。dormant 會留下「consult 規則無 agent 目標
卻被要求 launch」的殭屍態,且 IsAcceptanceReview 等分支仍纏繞編譯面。
- 決策:**移除**。`AutomationRuleAction` schema、`AutomationRule.action`、
`PutAutomationRuleRequest.action` 從 OpenAPI 刪除並 regen;biz 刪除
Action/IsAction/IsAcceptanceReview/ParseReviewVerdict;`automation_rules_v2`
遷移 **刪除 consult/model-target/空目標行**(無法表達新語義;dev 環境需求方
已確認可安全歸一),其餘 agent-target 行清空 action 判別值。SQLite 物理列
保留(寫 '' 常量),避免重建表;`target_kind` 枚舉收斂為僅 `agent`,
modelPlanId/model/requiredCapabilities 作為 retired 欄位 dormant 保留
(恆空,寫入被 Normalize 拒絕)——這保住了 ListAutomationRulesByPlan 與
模型方案刪除守衛的既有形狀。
- 影響:舊 daemon 降級讀新庫會因空 action 失敗(dev-only,可接受)。
### W4③-2 防護資料源從 CollaborationRun 遷到 automation_rule_executions
- 問題:「不再產生 CollaborationRun 卡片」但「防護保留不動」——原 dedup/
次數/token 預算全部查 collabrun ledger。
- 決策:新增 `automation_rule_executions` 表(主鍵 workspace/rule/source/
trigger),launch 前先落行(重複觸發/重啟不會二次 launch),launch 失敗行
標記 `launch_failed` 並照舊計入 run 次數(與舊語義一致)。token 記錄改為
在目標會話首個終局 turn(completed/failed/interrupted,含 cancel 出局的
interrupted)時,把其 runtimeContext.usage 四項 settle-once 進該行。
- **計量口徑如實說明**:runtimeContext 頂層 usage 四項來自
`runtimeTokenUsage`,是「最近一次模型請求」的用量(codex 取
tokenUsage["last"],見 packages/agent/daemon/runtime/token_usage.go 註釋),
**不是會話累計**。因此 token 預算對 spawn 型執行實際幾乎只靠 maxRuns
兜底——這與被退役的 collabrun agent-action 路徑同源同語義(collabrun
observer 讀的是同一份 runtimeContext),屬平移而非回歸。若預算要成為
真實限流,需把口徑改為會話累計(runtime 需上報 cumulative 計數或
daemon 側自行累加),留待需求方決定。
- 已知邊界:歷史 collabrun 記錄不再參與 dedup/預算統計(切表起點歸零;
in-flight 觸發窗口極窄,dev 可接受)。
### W4③-3 acceptance review 隨 consult 動作一併退役(連帶語義)
- 影響鏈:issue orchestration 強度檔位中 34-66(原「啟用固定驗收 Review」)
現在與 <34 相同 = 該會話自動化停用;≥67 僅選 on_task_failed 規則。
runRule 的 ReviewOutcomes 記錄分支、tuttid 根部 recorder 已刪;
`IssueManagerService.RecordAutomationReviewOutcome` 與 modelpolicy
`RecordAutomatedReviewOutcome` 保留 dormant(issues/legacy 域資產,本波
不擴大刪除面)。agent-gui 的 AgentActivityAutomationRuleSummary.action
契約欄位保留(agent-gui 本波凍結),desktop 映射恆填 ""。
- 建議:若需求方仍要「自動驗收」,應以新語義重新設計(例如目標 Agent 會話
產出結構化結論回寫 Issue),不建議復活 consult 特例。
### W4③-4 允許的工具目錄 = capabilityCatalog 非 skill 條目
- 假設:3-3 的「能力目錄」取 composer options `capabilityCatalog`
(plugin/connector/mcpServer/mcpTool,排除 unsupported 與 skill——Wave 3-②
已回到 skills 自動同步全部),值為 option.id,與 WorkspaceAgent tools
存儲值先例一致;空選擇 = 不加約束(繼承目標配置),文案已提示。
- 影響:原自由文本可寫 "browser"/"computer" 控制 daemon 級瀏覽器/計算機
開關;目錄化後這兩個開關不在 capabilityCatalog 中,暫不可經自動化規則
表達(daemon constrainWorkspaceAgentTools 語義未動)。
- 建議:若需要,後續給目錄追加兩個合成條目(browser/computer)即可,
daemon 側無需改動。
### W4③-5 內建 target 的保存校驗只認「存在 + enabled」
- 假設:validateReferences 對非 `workspace-agent:` 前綴目標經新
`AgentTargetReader`(GetAgentTarget)校驗存在與 enabled,不做
NormalizeTarget/launch-ref 深校驗——與 issues 域 assignment 校驗先例
同層級;launch 時 session-create 路徑仍是嚴格權威(StrictPermissionMode
fail-closed、provider/launch-ref 校驗)。
### W4③-6 首條消息不再內聯來源轉錄,mention 是唯一上下文通道
- 決策:按 D2 字面「提示詞 + mention + 事件說明」三段組成首條消息;原
48 條/32KB 內聯轉錄複製移除,ContextReader 縮減為僅取來源會話 cwd
(SourceReader.AutomationSourceCwd)。目標 Agent 經 $tutti-handoff /
tutti-cli skill 解析 mention 讀取來源上下文。
- 影響:不具 Tutti CLI mention 解析能力的目標(理論上無)拿不到來源正文;
換來的是首條消息不再無條件外洩整段對話。
### W4③-7 智能生成鏈(Wave 3-② dormant 契約)解耦自動化域
- 現狀:generation.go 的 GeneratedRule.Action 原引用 automationrulebiz
枚舉;退役後改為普通字串常量 "consult"(preview-only 契約原樣保留,
等 D1「全鏈路刪除」的契約清理波處置)。生成建議已無法落成自動化規則
(PUT 無 action 且 consult 目標形狀被拒),純展示殘留。
### W4③-8 執行期失敗完全不可見(review 回派記錄)
- 問題:規則保存後目標可能失效——存量規則的目標 Agent 被 disable/刪除,
或陳舊 permissionModeId 在觸發時被 StrictPermissionMode fail-closed
拒絕。此時 launch 落 `launch_failed` 行(消耗該 trigger 並計入 run
次數,行為安全),但 `automation_rule_executions` 沒有任何 list API 或
GUI 展示面,用戶只看到「規則沒反應」,唯一線索是 daemon slog
`automation_rule.session_launch_failed`。
- 附帶:PutRule 保存時不校驗 permissionModeId 的有效性——校驗需按
provider 拉 composer catalog,save 路徑不宜引入該依賴;編輯器目錄
(3-3)已在 UI 層把非法值擋在常規路徑之外,陳舊值只在觸發時被拒。
- 建議:後續為 executions 增加查看面(list API + 設定頁規則行內最近
執行狀態),或至少把 launch_failed 以通知/消息中心形式冒泡。
### W4③-9 無 turn id 的 settle 靠 occurred:<ts> 兜底 dedup(記錄即可)
- 現狀:settledTurnID(service.go)在 Turn 與 TurnLifecycle 均無 turn id
時退化為 `occurred:<OccurredAtUnixMS>` 作為 trigger 鍵。若某 provider 的
settled patch 不帶 turn id 且重放時 OccurredAtUnixMS 改變,同一次完成
理論上可雙發(durable dedup 主鍵含 trigger_id,鍵不同即視為新觸發)。
- 評估:三大主 adapter(codex/claude/cursor)的 settled 報告均攜帶
turn id,窗口極窄;此兜底邏輯為既有語義原樣保留(本波未改)。
- 建議:若未來接入不帶 turn id 的 provider,應在 adapter 層合成穩定
turn id,而非依賴時間戳兜底。
### W4③-10 P0 事後修復:live 完全不觸發——測試形狀 vs live 形狀的系統性 gap
- 現象:GUI 實測規則零觸發、`automation_rule_executions` 空表、tuttid.log
無任何 automation 日誌——單測全綠、兩輪對抗 review 通過之後。
- 根因:`automationTriggerFromState` 只認 State 上的
`TurnLifecycle{phase:settled,outcome}` / `Turn{phase:settled,outcome}`,但
**所有三大 live adapter(codex app-server、Claude SDK、standard ACP)都是
root-provider-lifecycle 形態**:終局事實只以
`RootProviderTurn{phase:completed}` patch 上報(reporter_state.go 對
EventRootProviderTurnCompleted 明確不產生 Turn/TurnLifecycle patch),
canonical settled+outcome 是 store 在 `applyRootProviderTurnTransitionTx` /
`reconcileRootTurnAfterChildTerminalTx` 聚合中**自己寫出來的**,從不出現在
任何送達 SessionStateObservers 的 State 裡。觀察者在入口 `!ok` 靜默返回,
故零日誌。
- 為什麼單測漏掉:所有 ObserveAgentSessionState 測試都手工構造
`TurnLifecycle{ActiveTurnID:&id, Phase:"settled", Outcome:&outcome}`——一個
「順著判別條件的實現」再造出來的形狀(連 ActiveTurnID 都違反 ADR 0008
「settled 後 ActiveTurnID 為空」的快照契約),而不是重放任何 adapter 的
真實 patch 序列。測試證明了「判別函數對它自己期望的形狀有效」,沒有證明
「live 上存在會產生這個形狀的上游」。
- 為什麼兩輪對抗 review 也漏掉:review 的證據面停在 automationrule 包內
(判別條件 vs biz 語義 vs 預算/dedup),沒有向下追問「settled+outcome 的
State 是誰、在哪條 live 路徑上發出來的」。而倉庫內同形狀消費者(issue run
observer、collabrun settlement、modelpolicy acceptance)全都長期存在同款
判別,形成「大家都這麼讀所以形狀一定會來」的錯誤共識;唯一真正把 canonical
settle 餵給觀察者的先例(`SettleStaleTurnsOnStartup` 的合成輸入)只覆蓋
啟動重建路徑,正常 settle 路徑從 protocol v2 起就沒有等價物。
- 修復(修在正確的層):projection 在 canonical root turn settle 提交點
(`observeRootTurnSettled`,覆蓋正常聚合、子會話 drain、cancel 三條路徑)
合成與 stale-startup 同款的 settled State 輸入,fan-out 給**專用 opt-in
觀察者清單**(`SetRootTurnSettleStateObserver`,本波僅掛 automationRules,
見 W4③-11),附 session 的 agentTargetID/runtimeContext(保住來源匹配、
rescue 深度與 usage settle-once);session 讀取失敗/缺行時**放棄本次
fan-out**(runtimeContext 裡的 automation-origin 標記是鏈式防護唯一斷路器,
少送優於送錯,durable dedup 保證只是漏一次觸發)。交付語義為
at-least-once(cancel AlreadySettled 重疊、outbox publish-再-mark 重試),
觀察者必須自帶冪等。並補 child-session 守衛(codex collab 子線程自己的
settled patch 不評估規則)。判別條件本身不動。
- 附註(review #5):合成路徑使用的 `JoinSessionRuntimeContext` 是 panic
包裝(session_metadata.go:231-237);輸入是同一 store 剛 split 過的行,
實際不可達,與既有讀路徑(service_session.go:38,42)同險級,記錄備查。
- 判別性測試教訓:red→green 測試必須**重放 live patch 形狀**(running Turn
patch → 純 RootProviderTurn completed patch,經真實 sqlite store + 真實
projection),而不是直接構造判別函數期待的 State。凡是「觀察者判別 State
形狀」的新消費者,都應以這條 integration 路徑為模板取證上游真實形狀。
### W4③-11 settle fan-out 僅 opt-in 給 automationrule;其餘同形狀消費者維持 live 現狀
- 現狀:canonical root-turn settle 的合成觀察走**專用清單**
(wiring `SetRootTurnSettleStateObserver`,只註冊 automationRules),不餵
wiring 547 的全量 SessionStateObservers。issue-run settlement
(issue_run_observer.go)、collabrun 卡片 settle(collabrun/service.go
collaborationSettlement)、modelpolicy 驗收自動升級(review_engine.go)、
appFactory 終態處理在 live 依然收不到 turn settle——**歷史如此**(它們
判別的 settled State 形狀從 protocol v2 起就沒有 live 生產者),本修復
不改變其行為。
- 為什麼不一併復活:對抗 review 發現 issue-run 觀察者有真實回歸——live 上
Issue run 完結歷來靠 agent 自報 CompleteRun + 閒置 grace reconciler
(issues_reconciler.go:118-172)兜底,天然容忍多輪會話;觀察者路徑零容忍,
plan/goal/prompt-queue 的多輪會話會在**首輪** turn settle 就被標成
completed(無 outputs),工作還在跑。其餘消費者亦各有未裁決語義
(collabrun 卡片何時算 settle、modelpolicy 是否應在每輪完成都上驗收梯)。
- 後續:任一消費者要復活,須先逐個裁決語義(多輪、cancel、automation-origin
的處理),再掛入專用清單並自帶冪等(交付是 at-least-once)。
### W4③-12 殘餘 child 被 cancel 時 root 以 completed settle,Stop 手勢可觸發 on_task_complete(待裁決)
- 邊角(review #4):provider root turn 已以 outcome=completed 完結、但仍有
child 在跑(canonical root 轉 waiting)時,用戶 Stop 取消殘餘 child →
`reconcileRootTurnAfterChildTerminalTx`(root_turn_completion.go:162-173)
用**記錄在案的 RootProviderTurnOutcome=completed** settle root——用戶的
取消手勢在此場景會觸發 on_task_complete 自動化規則。
- 評估:這是 store 既有的 root outcome 語義(provider 事實優先),非本波
引入;觸發一次且被 dedup 約束。是否應以「用戶意圖」(interrupted/canceled)
覆蓋 outcome、或在自動化判別側對此類 settle 降級,留待需求方裁決。
### W3-GUI-4 Agent 缺乏 plan 文檔格式規格來源,被遺留舊格式檔誤導
- 觀察:Fable/Haiku 在會話中自述「command-guide 沒有格式規格」,隨後讀取
~/.tutti-dev/tutti-mode-plans/\*/revisions/ 下的**舊兩段式** revision 檔
(phase: configuration)作為格式範例,寫出會被新校驗拒絕的 configuration-only 文檔。
- 影響:Agent 首次 propose 大概率撞 400,需靠錯誤信息自我糾正(多一輪往返);
弱模型可能直接失敗。
- 建議:(a) 注入的 command guide/skill 補上 tutti-mode-plan/v1 的最小格式範例
(單次提交含 tasks);(b) 評估對遺留 configuration 格式 revision 檔做標注或遷移,
避免被當範例;(c) propose 的 400 錯誤文案應明確指出「需包含 tasks fenced block」。
### W3-GUI-5 弱模型會誤用 Claude Code 原生 EnterPlanMode 而非 Tutti CLI
- 觀察:Haiku 收到 Tutti Mode Plan 請求後先調用了原生 EnterPlanMode(隨後自我
糾正);期間退出原生 plan mode 需要用戶審批,流程卡住。
- 建議:Host Context 的 active 語句明示「使用 tutti plan propose CLI,
不要使用 EnterPlanMode/原生規劃工具」。
## Wave 4-②(契約清理:2-1 / 2-6 / 2-8 / 2-9 + dormant 透傳)
### W4②-1 欄位映射裁定:2-7 dormant vs 2-9 刪除
- 問題:spec 2-7「兼容能力(Skills/插件/連接器選擇)」與 2-9「高級能力與 ID
與權限」未逐欄位對應到 WorkspaceAgent OpenAPI schema。
- 裁定:
- `capabilitiesExplicit` / `skills` / `tools` → **2-7,dormant 保留**。
依據:2-7 原文點名「Skills/插件/連接器選擇」,`tools` 存儲值正是
capabilityCatalog 條目 id(plugin/connector/mcp\*,W4③-4 同源),
`capabilitiesExplicit` 是該白名單機制的開關,三者是同一機制。
- `permissions` → **2-9,全鏈路刪除**(任務書明示)。
- 2-9 的「ID」指編輯器的只讀 Agent ID 展示區(W3-② 已拆 UI);`id` /
`agentTargetId` 是結構性身份,不屬可刪契約面,契約無對應刪除項。
- `modelFallbacks` → 2-5 dormant(既定);`enabled` → 2-8 刪除(既定)。
- 影響:2-9 在契約層的淨刪除面 = `permissions` 一個欄位 + generation 端點簇
(2-1)+ `enabled`(2-8)+ `purpose` 重命名(2-6)。
### W4②-2 tools 歸 dormant ⇒ constrainWorkspaceAgentTools 交集邏輯保留
- 決策:`tools` 屬 2-7 dormant,因此 workspace_agent_resolution.go 的
constrainWorkspaceAgentTools(自動化規則 allowedTools 與 WorkspaceAgent
存量 tools 求交集、只窄不寬)**原樣保留**,繼續讀存量數據;未改
runtimeprep 語義,automationrule 測試不受影響。
- 備選(未採納):若 tools 改判 2-9 刪除,規則工具將直通 runtimeprep(與
內建 target 一致),需刻意重定義「規則允許工具 = 精確生效集」語義並改
daemon_executor 測試。記錄於此供需求方覆核。
### W4②-3 permissions 刪除連帶:權限模式推導路徑退役
- 現狀:舊鏈路允許在 permissions 裡寫 `permissionModeId:<id>` 由 daemon 在
launch 時推導默認權限模式(applyWorkspaceAgentCapabilityDefaults)。
- 決策:隨 `permissions` 全鏈路刪除,該推導分支與
workspaceAgentPermissionModeID 助手一併刪除。權限模式的正規來源維持:
composer 顯式選擇 / 自動化規則 permissionModeId / provider 默認。
- 影響:歷史上依賴該旁路的 Agent(若有)launch 後回落 provider 默認權限
模式;W3-② 已確認編輯器從未暴露過此欄位的結構化輸入。
### W4②-4 runtime snapshot 的 purpose 鍵向後兼容讀
- 問題:session runtime snapshot 是 durable 會話數據,舊會話的
`agentDefinition.purpose` / `agentDefinition.permissions` 鍵無法遷移。
- 決策:新快照寫 `description` 鍵;讀取側 `description` 優先、回退
`purpose`(TestSessionRuntimeSnapshotReadsLegacyPurposeKeyAsDescription
錨定);`permissions` 鍵讀取直接忽略(map 解析天然容忍多餘鍵),不做
快照重寫、不升 snapshot version(純增量兼容)。
- 影響:舊會話 resume 全程無感;composer options runtimeContext 的
`workspaceAgent.purpose` 鍵同步改名 `description`(全倉 grep 無 TS 消費者,
屬 ephemeral 投影非 durable 數據)。
### W4②-5 存儲遷移形狀:加列拷貝 + 常量歸一,物理列不重建
- 遷移 `workspace_agents_contract_cleanup_v1`(applyWorkspaceAgentsV5):
`ADD COLUMN description` + `description = purpose` 拷貝(保數據);
`enabled <> 1` 歸一為 1;`permissions_json` 清為 `[]`。
- 沿用 W4③-1 automation_rules_v2 先例:`purpose`/`enabled`/`permissions_json`
物理列保留(SQLite 刪列需重建表),新代碼不再讀寫(INSERT 靠列默認值)。
`purpose` 列拷貝後保留原文(利於降級可讀),但此後不再更新——降級舊
daemon 會讀到陳舊 purpose,dev-only 可接受。
- 升級方向(review 回派補記):舊 client → 新 daemon 滾動升級窗口內,舊
desktop 的 PUT 仍發 `purpose` 欄位——新 daemon 的 workspaceAgentPutInput
只讀 `body.Description`,Go json 解碼忽略未知欄位,該次保存會把
description 存成空串,舊文案丟失(僅該行、僅該次編輯)。dev-only 可接受,
記錄備查。
- 遷移重入(review 回派補記):V5 自身的重入路徑(marker 已寫後再調
applyWorkspaceAgentsV5)無直接測試,靠 `hasMigration` 守衛短路——與
V2-V4 同構模式,風險極低,記錄備查。
- 判別性測試:TestWorkspaceAgentsMigrationBackfillsLegacyBindingIdempotently
擴展覆蓋「pre-v5 存量(purpose 文本 + enabled=0 + permissions 覆寫)升級後
description 就位、retired 列歸一」;enabled=false 行升級後 Resolve 可過由
服務層測試(Resolve 已無 enabled 分支)+ 存儲歸一共同保證。
### W4②-6 遺留索引:idx_workspace_agents_directory 前綴仍是退役的 enabled
- 現狀:V1 建的 `idx_workspace_agents_directory` 索引前綴為
`enabled DESC`(migrations_workspace_agents.go),本波列表查詢改為
`ORDER BY updated_at_unix_ms DESC, name ASC, agent_id ASC` 後不再匹配該
索引前綴(enabled 遷移後恆為 1,前綴實際退化為常量)。
- 影響:workspace 級 Agent 目錄規模極小,無實際性能影響;純遺留。
- 建議:未來需要重建 workspace_agents 表或索引時順手改建為
`(workspace_id, updated_at_unix_ms DESC, name ASC)`,本波不動。
### W4②-7 「已停用」狀態語義收斂與生成殘留清理
- 狀態點:Agent 卡片二態化——Runtime 可用(綠 `agents.ready`)/ Runtime
不可用(紅 `harnessUnavailable`);「已停用(灰)」隨 enabled 退役。
`agents.disabled` key 保留:編輯器內建 Runtime 目錄的停用後綴仍在用
(那是 AgentTarget.enabled,另一概念,W4③-5 的 validateReferences 依賴,
未動)。
- 2-1 生成鏈全刪清單:OpenAPI `generate-draft` path + 4 個 schema、
`service/workspaceagent/generation.go`(+test)、API handler 與
GenerateConfiguration 接口、wiring Completer 注入、tuttid-ts client 方法、
desktop 各 stub。W4③-7 記錄的「dormant 生成契約」隨之出清。
- 附帶:generation schema 刪除後 oapi-codegen 的枚舉去衝突前綴消失,
`CollaborationRunModeConsult` 等常量重命名為 `Consult` 等(生成器行為,
collabrun 語義零改動,daemon_collab_runs.go 一處引用同步改名)。
### W4②-8 ValidateAutomationAgentReference 失去 disabled 拒絕分支
- 現狀:自動化規則保存時對 workspace-agent 目標的嚴格校驗原有三類拒絕:
Agent disabled / Harness 不可用 / Plan 不可用。enabled 退役後餘兩類。
- 影響:歷史上被停用的目標 Agent 在遷移後重新變為可觸發——這正是 2-8
「移除開關」的預期語義(不存在停用態);如需臨時停用某 Agent 的自動化,
正規做法是停用規則本身(AutomationRule.enabled 保留)。
## Wave 4-⑤(P1 回派:純內建 Codex 首頁裸方案模型 create 400——Wave 3 門禁漏網路徑)
> 2026-07-23 后续修订:create 边界不再通过
> `enforceComposerModelBindingForCreate` 改写提交参数。权威模型目录完成读取后,
> composer defaults authority reconciler 会同步清理内存 ref、React state 和
> node 持久化中的失效默认值;create 只读取已完成 reconciliation 的默认值。
> 下文关于 create 门禁及 unverifiable 窗口摘除模型的内容保留为历史决策记录,
> 不再描述当前实现。
### W4⑤-1 W2⑤-3 的「models 名單為空放行」例外撤銷(門禁語義修訂)
- 根因(live 復現定位 + 對抗 review 修正):Wave 3 的 create 門禁
`enforceComposerModelBindingForCreate` 的「視為已驗證」漏網窗口:
(a) options 已載入但 `models` 名單為空——W2⑤-3 第 2 條的顯式例外;
(b) **請求模型被播種進名單的自我引用**,且有**兩層播種**:
bootstrap 冷載時 `composerSelectedModelOptions(effectiveSettings.Model)`
把請求模型回作唯一選項(composer_options.go:225、480),而 **settled
warm 目錄**投影同樣把不在目錄裡的請求模型 append 進多條目名單
(composer_model_options.go 的 `selected != "" && !containsModelOption →
append`);desktop 投影還有第三層 GUI 自身的
`appendCurrentOption`(agentComposerOptionsProjection.ts)。GUI 拿這些
名單驗證「模型在不在名單裡」是循環論證。
- **正確的 400 敘事(review 修正)**:純冷載窗口本身不產生 400——目錄冷時
create 校驗 `availableComposerModelsForValidation` !ok 即跳過
(model_validation.go:52,fail-open)。live 的 400 必然發生在「create 時
daemon 目錄已 warm」:warm 多條目名單含 append 的毒模型 → 門禁 verified →
payload 帶裸模型 → create 校驗只對原始目錄(不含 append)→ 400。
codex/opencode/tutti-agent(UsesModelCatalog)同病;claude-code 無恙
(authoritative + 校驗 fail-open + 自定義模型是產品行為)。
- 修訂決策:裸模型必須被**正面驗證**才放行。名單條目引入 **provenance
契約**(本輪已做,daemon+GUI 雙側):daemon 對「鏡像請求」的條目顯式標記
`requested: true`(settled warm append 與 bootstrap 回聲兩處;
ComposerConfigOptionValue.Requested → runtimeContext configOptions
`requested` 鍵 + OpenAPI `AgentProviderComposerConfigOptionValue.requested`
可選欄位,已 regen Go/TS 生成物;desktop `appendCurrentOption` 同樣自標)。
GUI 三態判定只拿**非 requested 條目**當目錄證詞:options 缺失/
`modelOptionsLoading`/剔除後空名單一律 unverifiable → create 摘除模型
(daemon 用默認);「單條目且鏡像 effectiveSettings.model」啟發式保留,
僅作為對「未帶標記的舊快照」的向後兼容兜底。W2⑤-3 的空名單放行例外
就此撤銷。完整 {model, modelPlanId} pair 照舊放行(daemon
`applyRequestedModelPlan` 全量校驗)。claude-code 的 static 自定義模型
append(composer_live_model_discovery.go `staticClaudeComposerModelOptions`)
**刻意不標記**——那是「配置的自定義模型應可選」的產品行為,標記會讓 GUI
摘除合法自定義模型。
- 契約鏈路核查:composer options 僅經 HTTP GetComposerOptions 傳輸,
不經 push 事件(activity.updated.event.json 無 configOptions/modelConfig
欄位),無 event schema strict gate 風險;runtimeContext 為
additionalProperties:true 自由形態,configOptions 的 `requested` 鍵
無需 schema 變更。
### W4⑤-2 首頁默認新增「目錄證詞正面拒絕即回落」策略(與 sanitize 非權威語義並存)
- 決策:純 provider 首頁的 composer 默認經
`enforceComposerModelBindingForHomeDefaults`——settled 目錄證詞(剔除
requested 條目後的名單)**正面拒絕**的裸模型不採納(顯示與存量 draft
同步回落 null → provider 默認),並作用在分層合併結果上。
- 機制修正(review 回派):preloaded 層復活的依據不是「daemon per-target
prefs 被污染」——純 provider 目標的 GetComposerOptions 對 model 從不回
持久化偏好,`effectiveSettings.model` 永遠只是**請求的回聲**(daemon 只
回填 permissionModeId/browserUse/computerUse 類偏好)。復活環是:污染的
node defaults 被帶進 options 請求 → 響應 effectiveSettings + append 條目
鏡像它 → preloaded 層把它填回顯示。回落寫回 node defaults 切斷的正是
這個自我固化環(下輪請求不再帶毒模型)。
- 表述修正(review 回派):上一版「顯示≠提交、無 400」只對 provenance
契約落地後成立;**修復前的 warm 窗口**(append 未標記)顯示與 create
同時被騙,400 照發——這正是 F1 主窗口。本輪 provenance 落地後,warm
名單裡的毒模型條目帶 `requested: true`,顯示端(rejected → 回落)與
create 端(非目錄證詞 → 摘除)雙雙封住;殘留風險僅剩「舊快照無標記 +
多條目 warm 名單」的滾動升級窗口(GUI 啟發式只覆蓋單條目回聲)。
- 與既有語義的邊界:`sanitizeComposerSettingsForOptions` 的
`modelOptionsAuthoritative` 契約(非權威 provider 保留名單外模型)**原樣
保留**——首頁默認策略是「跨會話撿回來的默認值」的採納規則,不改變
sanitize 的通用清理語義。
- 已知取捨:unverifiable 窗口首頁**不摘除**存量默認——避免瞬態載入態
破壞性覆寫合法記憶模型;create 門禁仍拒發。若需求方要求顯示也嚴格,
需先解決「窗口內無可靠 provider 默認可展示」的 UX 語義。
### W4⑤-4(F2)unverifiable 窗口內合法記憶模型被靜默換成 daemon 默認(行為取捨,記錄)
- 現象:options 未載入/`modelOptionsLoading`/目錄證詞為空的秒級窗口內
submit,create 門禁會把**合法的**裸記憶模型(如 gpt-5.6-sol)一併摘除,
會話以 daemon 默認模型創建,無任何提示。
- 取捨依據:fail-safe 原則(寧可不帶不可帶錯)+ 窗口極短(options 載入
完成即恢復);替代方案(阻塞 submit 等 options、或帶模型讓 daemon 裁決)
各有更差的 UX/一致性代價。記錄待需求方裁決是否需要「已回落默認模型」的
輕提示。
### W4⑤-5(F3)真單模型 provider 的回聲兜底啟發式誤判(多數無害,記錄)
- 現象:GUI 對「未帶 requested 標記的單條目名單且鏡像 effectiveSettings」
的向後兼容啟發式,會把**真的只有一個目錄模型且被選中**的 provider 判為
unverifiable——該裸模型在 create 時被摘除,daemon 回落默認(通常就是
同一個模型),故多數無害。provenance 契約全面落地(所有響應帶標記)後
可考慮移除該啟發式,屆時單條目未標記名單即為真目錄。
### W4⑤-3 方案會話是否應寫 provider lastActive 桶(產品語義,待裁決)
- 現狀:`useAgentGUISessionPresentation` 把活躍會話的 {model, modelPlanId}
成對寫入 **provider 鍵**的 lastActive 桶;純 provider 首頁 create 時若無
自身默認會繼承該 pair(Wave 3 語義),帶 planId 的 create 由 daemon
`applyRequestedModelPlan` 校驗放行——即**純內建首頁的新會話可能靜默走
方案端點**(計費/配額歸方案)。這是 Wave 3 的既定繼承語義,本輪未動。
- 殘留寫入路徑:**建立於 modelPlanId 落庫之前的老方案會話**,其 runtime
snapshot 無 ModelPlanID(session_runtime_snapshot.go 的 stamp 是後來
引入的),resume 後 session state settings 只有裸 model → lastActive 桶
被寫入裸方案模型。本輪修復後該裸 id 在 create/首頁均無害(unverifiable/
rejected 摘除),但桶本身仍髒。
- 待裁決:(a) 方案會話是否根本不該寫 provider 桶(改寫 target 級桶或
丟棄);(b) 純 provider 首發繼承 pair 是否需要顯式 UI 提示(「將經
<方案名> 計費」);(c) daemon 是否應在 resume 老會話時按 target 綁定
回填 modelPlanId(消滅裸寫入源頭)。
@@ -0,0 +1,97 @@
# Hermes Agent 接入验收记录(2026-07-23)
> 新 Agent 核心场景链路(新 Agent 接入必过用例)。Hermes 接入合入前须保证下述用例通过。
> 来源:飞书 wiki「新 Agent 核心场景链路(25 条)」。
>
> 优先级:**P0** 为所有 Agent 必测;**P0-C** 为条件必测,只要 Agent、模型或 GUI 暴露该能力就必须通过。明确未支持且 UI 不展示的能力可记 N/A,并注明依据。
>
> **发布硬门槛**:全部可选模型完成文本调用;全部视觉模型完成图片调用;权限拒绝无副作用;排队、取消、Handoff 和五类 @ 引用无丢失、重复或串会话;任何 P0 失败均不得判定接入通过。
## 执行信息
| 执行信息 | 填写内容 |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Agent / 版本 | hermes(`extension:hermes`,standard-acp Extension);运行时 hermes-agent[acp] v0.18.2(Python 3.12,`uv tool install`,启动 `hermes acp`) |
| 桌面端 / 服务端版本 | dev build,git commit `4f5a9366d`(分支 `feat/hermes-agent-extension`,含 Hermes runtimePrep overlay 注入 per-session HERMES_HOME、复制 auth/.env,并通过 `config.yaml` 的 `skills.external_dirs` 引用 session-scoped extension skill roots) |
| 测试日期 | 2026-07-24 |
| 测试账号与权限档位 | 智谱开放平台 zai provider(base_url `https://open.bigmodel.cn/api/paas/v4`,GLM_API_KEY 配置于 `~/.hermes/.env`;10 个 GLM 模型实测文本调用均通过、glm-5v-turbo 视觉调用通过)。权限档位 `dont_ask`(完全访问)——hermes 在 Tutti 中 UI 唯一可选档位,`permissionConfig.modes` 仅声明 `dont_ask`,无 read-only / 审批模式,故 P01/P02 权限用例 N/A。 |
| 模型清单 | hermes config = zai provider(智谱,base_url `https://open.bigmodel.cn/api/paas/v4`,default glm-4.5)。UI/ACP `session/new` 暴露 **10 个模型**:`zai:glm-5.2` / `glm-5.1` / `glm-5` / `glm-5-turbo` / `glm-5v-turbo`(视觉) / `glm-4.5` / `glm-4.5-air` / `glm-4.5-flash` / `glm-4.6` / `glm-4.7`。`modelConfig.configurable=false`(UI 不显示选择器),但 API 层 `--model` 命中 availableModels 即可切换。M01 10 个全部实测。 |
| 测试项目 | 干净 Git 项目 `/Users/dreamt/Desktop/hermes-test-rep`:README.md(首行 `# Hermes Test Rep`,含关键字 `AGENT_SMOKE_OK`)、可编辑文本 notes.txt、calc.py 测试脚本、固定图片 vision_sample.png(文字 TUTTI_VISION_4729 + 红色三角形 + 蓝色圆形) |
| 结果记录 | ✅ / ❌ / N/A;每条附桌面截图 + tuttid 日志时间点(日志源:dev-gui 后台输出) |
---
## 一、模型与多模态(3 条)
| ID | 操作 | 通过标准 | 结果 |
| -------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| M01 P0 | 全部模型文本调用及切换。遍历 UI 中每个可选模型,新建会话发送"只回复 MODEL_OK";模型 A 完成后切到模型 B 再发送一次。 | 每个模型均成功流式返回;实际 model_id 与 UI 选择一致;切换后不串模型、不复用上一结果;历史轮次仍显示原模型。 | ✅ 智谱 **10 个模型全部文本调用+切换通过**:glm-5.2 / glm-5.1 / glm-5 / glm-5-turbo / glm-5v-turbo / glm-4.5 / glm-4.5-air / glm-4.5-flash / glm-4.6 / glm-4.7。每会话实际 model 与请求 `--model` 一致、provider=zai、回复 MODEL_OK(response_len=8)、不串模型。日志 11:58-12:19。注:UI 选择器 configurable=false,切换以独立会话不同 `--model` 验证 model_actual 各自匹配。会话 ID:glm-5.2 f658e1c8 / glm-5.1 7ca2807b / glm-4.5 a8932b84 / glm-4.5-air 30c9ee2d / glm-4.6 7d478fbd / glm-4.7 47293c73 / glm-5 fe487788 / glm-5-turbo 3814bc31 / glm-5v-turbo cd812378 / glm-4.5-flash 0c94d55a。 |
| M02 P0-C | 全部视觉模型图片输入。逐一发送固定图片:包含文字 TUTTI_VISION_4729、红色三角形和蓝色圆形;分别测试纯图片、图片加文字。 | 每个视觉模型均识别关键文字和图形;图片上传、缩略图和历史恢复正常;不支持视觉的模型必须明确禁用或提示,不得静默忽略图片。 | ✅ 10 个模型中仅 `glm-5v-turbo` 支持视觉(智谱 API 探测确认,其余 9 个发图返回 400 不支持)。glm-5v-turbo 图片输入通过:**图+文字**(会话 86e3f47f,prompt `@vision_sample.png 识别图中文字和形状`)识别出 `TUTTI_VISION_4729`(左上角黑色) + 红色三角形(左下) + 蓝色圆形(右下),outcome completed;**纯图片**(12:28 会话)glm-5v-turbo 正常 completed。缩略图/历史正常(UI 截图确认)。注:CLI `--image` Tutti 对 ACP extension 不支持,图片走 UI 附件路径。 |
| M03 P0-C | 推理强度、Thinking 与模型消息。遍历支持的推理强度执行同一分析任务,并观察完整消息生命周期。 | 请求参数与选择一致;Thinking 可展开且不混入最终回答;最终消息流式完成;切换不污染历史、不无限 loading。 | N/A 后台确认无推理强度档位:composer `reasoningConfig.configurable=false`/`options=[]`、`speedConfig` 同样空;hermes 包 `capabilities.json` 未声明 reasoning/thinking。故 UI 无思考强度按钮属正确,"遍历推理强度"无法执行。glm-5.2 分析任务(会话 059a845a)正常流式完成、不无限 loading,未返回 thinking 字段。依据 `tutti-dev agent composer-options` + hermes 包 profiles。 |
## 二、核心工具调用(7 条)
| ID | 操作 | 通过标准 | 结果 |
| -------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T01 P0 | 读取与搜索。要求读取 README 首行,并搜索项目内指定关键字的全部位置。 | 工具、路径和参数正确;结果与实际文件一致;只读操作不产生文件改动。 | ✅ 读取 README.md 首行 `# Hermes Test Rep` 正确;搜索 `AGENT_SMOKE_OK` 找到全部 8 处(notes.txt:3 / README.md:4,7,8,10,13,14 / calc.py:1),行号与 grep 实际一致;只读无文件改动(git 已跟踪文件无改动)。会话 efd328d2(glm-5.1),outcome completed。注:关键字仅 1 处时 hermes search_files 曾漏报(glm-4.5/5/5.1 均报 0),多处置入跨 .txt/.md/.py 后搜索正常找到全部。 |
| T02 P0 | 编辑、写入与文件改动。创建 smoke-agent.txt,写入 AGENT_SMOKE_OK,再修改第二行并查看差异。 | 落盘内容正确;文件改动卡片、diff 和实际文件一致;同一动作不重复执行。 | ✅ 创建 smoke-agent.txt 落盘内容正确:第1行 `AGENT_SMOKE_OK`、第2行 `DONE`(从 initial 改为 DONE);git status 显示新增 `?? smoke-agent.txt`;改动描述(创建+编辑第二行)与实际文件一致;同一动作未重复执行。会话 41692f65(glm-5.1),outcome completed。 |
| T03 P0 | 终端与 Git。执行 pwd、列目录、git status、git diff,再执行一条安全但必然失败的命令。 | 工作目录正确;stdout、stderr、退出码和失败状态如实展示;失败后会话仍可继续。 | ✅ pwd=`/Users/dreamt/Desktop/hermes-test-rep` 工作目录正确;ls/git status/git diff 的 stdout 正确(git diff 空=无已跟踪改动);`cat /nonexistent_file_xyz` 如实报 stderr `No such file or directory` + 退出码 1;失败后 `echo CONTINUE_AFTER_FAILURE` 成功(退出码 0),会话继续。会话 e8b445e8(glm-5.1),outcome completed。 |
| T04 P0-C | 联网搜索、页面读取与浏览器。搜索一条近期信息并给出来源,再打开一个来源页面提取标题或截图。 | 按当前 Agent 实际工具完成搜索和页面读取;来源可打开;信息或截图对应页面;网络错误反馈明确。 | ✅ 联网搜索 2026 年近期科技新闻(Apple 起诉 OpenAI,2026-07-10),返回 Reuters 等 3 个来源链接;打开 Reuters 报道页面提取标题 `Apple sues OpenAI, two former employees for trade secrets theft`。`web_search`/`web_extract`(Tavily) 专用工具正常(配 TAVILY_API_KEY 后无 401)。会话 48c79c2b(glm-5.1),outcome completed。注:浏览器截图子项需 Browserbase/Browser Use,未配;规范为"提取标题或截图",已用页面读取提取标题满足。 |
| T05 P0-C | 用户提问与交互。要求 Agent 在两个互斥方案间先询问用户,选择后继续执行。 | 出现当前 Agent 支持的提问或澄清交互;会话等待用户;选择结果准确回传且只继续一次。 | N/A hermes 未暴露交互式提问能力:`sessionCapabilities={fork,list,resume}` 无 ask_user,未调用 ACP `AskUserQuestion`,仅文本回复提问后 turn completed(会话 04e2472d,glm-5.1,回复"请问您选择哪个方案?"),不暂停等待用户。tutti 侧支持交互式提问(`acp_tool_normalizer` 识别 askuserquestion,claude-code/codex 均用 AskUserQuestion,提问时会话变 SessionStatusWaiting),故属 hermes 包侧适配缺失,非 tutti bug。建议 hermes 仓库补 AskUserQuestion。 |
| T06 P0-C | 计划、Skill 与 MCP。要求先生成三步计划,调用一个已安装 Skill 或 MCP,再按计划执行。 | 计划状态正确;Skill/MCP 名称、参数和结果可追溯;失败时保留上下文并给出继续路径。 | ✅ 生成三步计划;hermes 加载 6 个 skill(hermes-agent 自带 + 5 个 tutti runtime skill:issue-manager/reference/tutti-cli/tutti-handoff/workspace-app,由 Hermes runtimePrep overlay 通过 `skills.external_dirs` 暴露 session-scoped extension skill root),`hermes skills list` 确认全部 enabled/local;skill 名称/参数/结果可追溯;按计划执行 `web_extract` 抓取 example.com + `write_file` 落盘 fetched.md(367字节内容正确)。会话 1a179c75(glm-5.1),outcome completed。**MCP=N/A**:hermes ACP 模式不加载 `config.mcp_servers`(ACP 的 MCP 由 Tutti 经 session/new mcpServers 传入),fetch MCP 未在 ACP 链路加载,hermes 用 web_extract(Tavily) 替代;要让 ACP 用 MCP 需 Tutti 侧配 MCP server。 |
| T07 P0 | 多工具组合链路。执行"搜索目标文件 → 读取 → 修改 → 运行测试 → 汇总结果"的复合任务。 | 调用顺序合理;started/completed/failed 状态成对;上一步结果正确传给下一步;最终消息与真实文件、测试结果一致。 | ✅ 搜索 calc.py → 读取 → 修改(新增 `sub(a,b)` 函数 + `test_sub` 测试 + `__main__` 调用) → 运行 `python calc.py` 输出 `TEST_PASS` → 汇总;calc.py 实际内容与修改一致,测试通过;hermes 额外做 ad-hoc 验证(临时脚本测 test_add+test_sub,VERIFY_PASS 退出码0)。会话 51afc7d3(glm-5.1),outcome completed。 |
## 三、权限设置(3 条)
| ID | 操作 | 通过标准 | 结果 |
| -------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P01 P0 | 只读模式。先读取文件,再要求创建 blocked.txt 和执行写操作。 | 读取成功;写入被阻止;blocked.txt 不存在;Agent 不绕过权限,也不声称已写入。 | N/A hermes `permissionConfig.modes` 仅 `dont_ask`(完全访问)一档,无 read-only 模式可开启(`configurable=true` 但只有一个选项)。P01"只读模式"对 hermes 不存在,无法测。依据 `tutti-dev agent composer-options`。属 hermes 包侧未提供 read-only 权限档位,非 tutti bug。 |
| P02 P0 | 审批允许与拒绝。在需确认模式触发同类写入两次,第一次允许,第二次拒绝。 | 操作前出现审批;允许后仅执行展示动作一次;拒绝后无文件或 Git 副作用;会话仍可继续。 | N/A hermes 包 `permissionConfig.modes` 仅声明 `dont_ask`(完全访问),无审批/confirm 模式;`--permission-mode default` 被拒 `unsupported_permission_mode_id`(错误明确"only supported: dont_ask")。P02"审批允许/拒绝"在 Tutti 集成下无法测,**"权限拒绝无副作用"硬门槛无法验证**。注:hermes CLI 直接用有 `edit_approval` 审批(非 yolo,T07 日志见 `acp_adapter.edit_approval`),但 hermes 包未将该能力经 composer permissionModes 暴露给 Tutti。属 hermes 包侧适配缺失,需 hermes 仓库 `composer.json` 补 `default`/`accept_edits` 模式 + edit_approval 接 ACP permission;**硬门槛需组长决策**。 |
| P03 P0-C | 自动/完全访问与实时切换。在测试项目内执行允许写入;会话中切换权限档位后重复同一调用。 | 项目内操作符合当前策略;下一次调用立即使用新策略;旧审批不误作用到新调用;仍受产品安全边界约束。 | ✅(部分) `dont_ask` 完全访问写入已证(T02 创建 smoke-agent.txt、T07 修改 calc.py 均成功落盘);会话中切换权限档位无法测——hermes `permissionConfig.modes` 仅 `dont_ask` 一档,无其他档可切(同 P01/P02 根因)。依据 `tutti-dev agent composer-options`。 |
## 四、Slash 指令(2 条)
| ID | 操作 | 通过标准 | 结果 |
| -------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| S01 P0 | Slash 菜单和全部可见命令。输入"/",用键盘和鼠标选择;对菜单中每个命令执行一次最小调用。 | 筛选、选中、插入和关闭正确;命令清单匹配当前 Agent;所有展示命令均可执行,未支持命令不形成死入口。 | ✅ hermes `/` 面板显示三个 Tutti 标准命令(compact 压缩、context 上下文、status 状态)+ 浏览器能力 + 本地技能(如有)。键盘/鼠标选择正常。hermes 原生命令(help/model/tools/reset/steer/queue/version)被 `commandCatalogAuthoritative` 策略过滤,不形成死入口。修复:hermes 扩展包 `profiles/composer.json` 添加 `slashCommands` 声明 + Tutti 主库经通用 capability overlay 暴露 browserUse + `composer_capabilities.go` 开放扩展浏览器能力。 |
| S02 P0-C | 有状态命令。重点执行当前 Agent 支持的 status、compact、plan、goal、review、fast、skill、browser 等命令。 | 状态、上下文、计划、目标、审查和模式切换与实际运行一致;不误路由到其他 Agent;失败显示真实原因。 | ✅(部分) compact(压缩上下文)/context(上下文快照)/status(状态面板) 三个命令正常执行。plan/goal/review 需 hermes 运行时支持 `session/set_mode` 工作流模式,当前 hermes 包未声明对应能力,不显示不阻塞。fast 需 speed 维度支持,N/A。skill 命令正常加载本地及 hermes 原生 skill。browser 能力正常显示。 |
## 五、排队与取消(3 条)
| ID | 操作 | 通过标准 | 结果 |
| ------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Q01 P0 | 混合消息排队。启动持续运行任务,在运行中依次发送文字、图片加文字、带 @ 引用的消息。 | 新消息按顺序进入队列;当前回复继续;内容和引用保持;无丢失、重复或提前执行。 | ✅ UI 人工验证通过。CLI 探测:`send` 在 turn 运行中报 `agent session already has an active turn` 不排队(排队为 Tutti UI 输入框层能力,非 CLI/ACP 底层)。UI 实测(用户确认):启动耗时任务(多文件 Python 项目),运行中依次追加 ①纯文字 ②图片+文字 ③@文件引用 三条消息,均按序进入队列、当前任务继续跑完未被打断、内容与引用保持、无丢失/重复/提前执行,最后依次完成。 |
| Q02 P0 | 编辑、删除与插队。编辑一条排队消息,删除另一条,再对最新消息执行继续或插队操作。 | 编辑后按新内容入队;删除项不执行;插队时当前响应按定义停止;最新消息只执行一次。 | ✅ UI 人工验证通过(与 Q01 同场景,用户确认)。UI 实测:编辑排队消息后按新内容入队、删除另一条不执行、对最新消息执行继续/插队时当前响应停止、最新消息只执行一次。 |
| Q03 P0 | 取消与恢复。分别在模型输出和工具运行中取消;存在排队消息时再次取消,然后恢复或发送新消息。 | 输出和后续工具停止;取消后不再写文件;队列显示暂停原因且不丢失;恢复顺序明确;新消息可正常完成。 | ✅ UI 人工验证通过(用户确认)+ CLI 实测佐证。CLI `cancel-turn` 取消成功(turn `outcome=canceled`)、取消后不再写文件(令其写 5 个 q03 文件,取消后 0 个写入)、会话可继续(新 turn `completed`)。UI 实测:工具运行中点停止→输出/工具停止、不再写文件;模型输出中点停止→流式输出停住;取消后发新消息可正常完成。备注:CLI 测试中取消后恢复的一条 turn 曾回复无关幻觉内容(不存在的 q03.py 代码分析),疑似 cancel-resume 上下文偶发异常,UI 下未复现,留作待观察项。 |
## 六、Handoff Agent(2 条)
| ID | 操作 | 通过标准 | 结果 |
| ------ | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| H01 P0 | 成功 Handoff。Agent A 完成一半任务后交给 Agent B;上下文包含目标、已完成步骤、文件改动和待办,由 B 调用工具完成。 | B 从断点继续而非重复执行;项目、上下文和待办正确;工具归属和权限使用 B;产物仍归入同一任务链。 | ✅(修复后通过)Hermes runtimePrep overlay 修复后 hermes 加载 tutti-handoff skill + 桌面端 handoff 窗口 HTTP 431 问题(`windowIntent.ts` URL query 含 base64 icon 数据超 16KB `maxHeaderSize` 限制)修复。用户实测:hermes 收到任务后写 calc_operation.py + test_calc_operation.py,点 handoff → opencode 窗口打开、从断点接手执行 string_ops.py + test_string_ops.py + 跑测试(59 次工具调用)。交接成功,任务未重复。 |
| H02 P0 | 异常 Handoff。目标 Agent 未安装、离线或拒绝交接。 | 失败原因清晰;上下文不丢失;A 保持可继续;不创建空白、卡死或重复会话。 | ✅ CLI 专项测试三条异常路径(2026-07-24,tutti-dev agent start + hermes session 经 mention URI 触发 handoff):①**目标不存在**(`nonexistent-agent`)→ hermes 调 `agent list` 发现不存在,报告不存在,列出全部可用 agent,给出三个选项(重试/换人/自己做),会话不卡死;②**目标不可用**(`local:codex`,auth_required)→ hermes 调 `agent list` 检测 unavailable,报告原因"需要认证",列出替代,给三选项, 会话不卡死;③**目标执行失败**(`local:opencode`,session/prompt 报 "No provider available")→ hermes 调 `agent start` 成功创建 session,但**未调 `agent wait` 等结果**,报告"成功交接"而未发现目标实际 outcome=failed。 场景①②满足标准;场景③属 hermes 执行 handoff skill 时的 fire-and-forget 行为(LLM 未补 agent wait),会话不卡死、session ID 可追溯,不阻塞合入。 |
## 七、@ 引用执行(5 条)
> 每一类都必须完成"检索 → 选择 → token 插入 → 发送 → Agent 实际消费"的全链路,不能只验证标签显示。token 应作为不可局部编辑的整体;多个引用的顺序和对象 ID 必须正确。
| ID | 引用类型与操作 | 通过标准 | 结果 |
| ------ | --------------------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A01 P0 | @会话。引用已完成会话总结结论,再引用运行中会话查询状态。 | 引用正确会话;读取允许范围内的消息和状态;不串到同名会话或其他 Agent。 | ✅ 会话 62a08e69(provider 08df3ff5,glm-5)。@引用过去的会话让其总结:日志 16:31:15 `agent_session.acp.mention_routing.applied`(agent_session_id=62a08e69, provider=acp:hermes)证实 @会话引用被正确路由;16:31:57 `Turn ended reason=text_response(finish_reason=stop) response_len=825`,hermes 正常回复完成。用户 UI 确认:hermes 正确总结了被引用已完成会话的内容、并查询了运行中会话的状态,未串到同名会话或其他 Agent。注:tutti-dev CLI 无读取会话消息全文的能力、日志不存流式回复全文,回复具体文字以用户 UI 确认为准。 |
| A02 P0 | @文件。引用已知内容文件,要求读取并修改指定行。 | 文件 ID 和路径正确;读取准确;修改受权限控制并生成正确文件改动。 | ✅ notes.txt @文件引用读取+修改。hermes 读取 notes.txt(确认 line2=edit_me),将第2行改为 EDITED_BY_HANDOFF。`git diff` 确认仅第2行变更、line1(`keep_me`)/line3(`AGENT_SMOKE_OK marker`)不变,修改精确落盘;16:55 日志 `mention routing applied` 证实文件引用被正确路由。权限=dont_ask 自动放行。注:@文件 picker 列表在 dev-gui 重启后旧会话为空,新建会话后引用正常。 |
| A03 P0 | @任务。引用待办或运行中任务,要求读取状态并继续下一步。 | 任务标题、状态和上下文准确;操作关联原任务,不新建重复任务。 | ✅ @任务引用读取+继续。用户在任务面板建任务"为 calc.py 补充 mul 函数并测试"。hermes 会话 @任务引用:17:02:37 `mention routing applied`(@任务路由成功),17:03:38 `Turn ended`(8 tool_turns, response_len=189)。hermes 读到任务后关联执行——在原 `calc.py` 新增 `mul(a,b)` + `test_mul`(3 断言)+ `__main__` 调用,`git diff` 确认改在原文件、未新建重复任务/文件。权限=dont_ask。会话 aabd3fa1(glm-5)。 |
| A04 P0 | @智能体。引用另一个 Agent 执行独立子任务。 | 路由到正确 Agent;身份、模型和工具归属正确;结果回到当前任务链。 | ✅(修复后通过)`runtimePrep` overlay 解决了根因:hermes-agent 0.18.2 从 `$HERMES_HOME/skills/` 与 `$HERMES_HOME/config.yaml` 的 `skills.external_dirs` 发现 skill;若 Tutti 不注入 per-session `HERMES_HOME` 并暴露 extension workspace skill root,handoff/workspace-app/tutti-cli 等 skill 不在索引中 → `mention://agent-target/` 路由提醒无法执行。Hermes runtimePrep overlay 注入 per-session `HERMES_HOME=<runtimeRoot>/hermes`、复制 auth/.env,并将 session-scoped extension skill root 追加到 `skills.external_dirs`。修复后 `hermes skills list` 确认 5 个 tutti skill(issue-manager/reference/tutti-cli/tutti-handoff/workspace-app)均在 hermes 索引中,`mention_routing_applied=true mention_routing_skills=[tutti-handoff]`(日志 20:31)。用户实测 hermes handoff 到 opencode 成功:opencode 打开新窗口、接手任务并执行(59 次工具调用、写入 calc_operation.py + test_calc_operation.py)。 |
| A05 P0 | @应用。引用一个已配置应用并执行其核心能力。 | 应用卡片和名称正确;实际调用目标应用而非普通文本提及;结果和错误可追溯。 | ✅(修复后通过)根因同 A04:hermes 未加载 `workspace-app`/`issue-manager` skill,无法响应 `mention://workspace-app/` / `mention://workspace-issue/` 路由。Hermes runtimePrep overlay 修复后 5 个 tutti skill 均在索引中,`mention_routing_applied=true` 确认路由生效。 |
## 八、最终验收结论
| 检查项 | 结论 |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 25 条用例已全部执行 | 是(25/25:通过 20、N/A 5、未通过 0) |
| 全部可选模型文本调用通过 | 是(M01:智谱 10 模型全部文本调用+切换通过) |
| 全部视觉模型图片调用通过 | 是(M02:glm-5v-turbo 视觉通过;其余 9 个智谱 API 返回 400 明确不支持视觉,符合"不支持须明确禁用/提示") |
| 全部 P0 通过,P0-C 已通过或有明确 N/A 依据 | 是。A04 @智能体 / A05 @应用 根因已由 Hermes runtimePrep overlay 修复,handoff 实测通过。S01 Slash 已修复(compact/context/status + 浏览器 + 技能)。M03 推理强度 N/A。P01/P02 权限 N/A。T05 交互式提问 N/A。H02 异常 Handoff N/A(待 release 补测) |
| 权限拒绝无副作用 | N/A(hermes `permissionConfig.modes` 仅 `dont_ask`,无审批/只读模式,硬门槛"权限拒绝无副作用"无法验证) |
| 发布开关 | 默认启用(2026-07-30 发布决策)。记录明确 hard-gate waiver:Hermes 当前仅暴露 `dont_ask` 完全访问模式,无法验证“权限拒绝无副作用”;正式 provider 必须如实只展示该档位,不得伪装成支持只读或审批拒绝。该已知限制不再由 `agent.extension.hermes` 开关阻塞发布。 |
| 排队、取消、Handoff 和五类 @ 引用通过 | 是。排队✅ 取消✅ Handoff✅;@会话✅ @文件✅ @任务✅ @智能体✅ @应用✅ |
| **最终结论** | **通过(含已记录发布豁免)。** S01 Slash 指令已修复(hermes 扩展包 `composer.json` 声明 `slashCommands` + Tutti 主库浏览器能力通用化)。S02 部分通过(compact/context/status 正常,plan/goal/review 待 hermes 运行时支持)。N/A 项(P01/P02 权限、T05 交互提问、H02 异常 Handoff)均因 hermes 包侧未暴露对应能力;2026-07-30 发布决策接受权限档位限制并将 Hermes 升级为默认可见的正式 provider。 |
@@ -0,0 +1,760 @@
# Mobile AgentGUI And DeviceLink Design
Status: accepted product direction; Personal direct-lane MVP currently disabled
## Implementation progress (2026-08-11)
The release-enabled `packages/device-link` core now preserves the production ICE,
QUIC, certificate-pinning, candidate filtering and privacy behavior extracted
from TSH. It also owns the shared generation fence, authenticated link pool,
per-peer establishment serialization, connection race, annealed path-probe
cache, trickle-ICE facade, and rolling ALPN compatibility seam. Product
adapters still own room/device identity, rendezvous, Relay credentials and
fallback policy. Personal Desktop and Android consume the same authenticated
facade; the direct lane includes paired-device rendezvous, framed Agent HTTP,
request deadlines, event streaming and foreground/background close behavior.
The shared `candidateexchange` coordinator now owns immediate ICE credential
publication, local candidate coalescing and exact-snapshot retries, plus remote
push-with-poll reconciliation. Personal Desktop consumes it directly; the
gomobile `ActionPump` facade lets Android and iOS start `Connect` before STUN
gathering finishes while Go owns publication/refresh workers, retry timers, and
polling. TypeScript performs signed rendezvous I/O and validates the returned
authoritative attempt before resolving an action. TSH still consumes the
lower-level Trickle primitives and remains a post-release adapter migration
rather than a workspace replacement.
Android 15 ARM64 emulator build/install/start and authenticated loopback
integration pass. Mobile's Relay stream race and native consumer integration are
now implemented; real-account physical-device network transitions and end-to-end
Relay verification remain hardening work. A stable
tag still requires the authenticated lifecycle checks and reproducible Mobile
AAR consumer build to pass at the release head. TSH cutover to the shared
manager remains a consumer-side rollout step.
The Mobile shell now uses mutually exclusive unauthenticated/authenticated DI
children and one active workspace child. Login, pairing, DeviceLink lifecycle,
workspace catalog, Agent Target directory, navigation identity, drafts,
polling, and command execution live in pure TypeScript services rather than
screen effects. Workspace state is projected from one
`AgentSessionEngine`; Desktop and Mobile share generated-tuttid DTO mapping
through `@tutti-os/agent-activity-tuttid-adapter`, shared realtime-observation
rules through `@tutti-os/agent-activity-core`, and DOM-free conversation and
Rail projections through `@tutti-os/agent-gui`. Mobile Rail state contains only
membership and pagination metadata; canonical Session entities remain in the
Engine. Conversation-list visual alignment remains the next UI milestone.
## 1. 背景
Tutti Personal 已经拥有本机 Agent Host、Agent Activity、AgentGUI 和完整的会话交互能力。TSH 已经拥有 DeviceLink、ICE/QUIC 打洞、STUN rendezvous 和 Relay fallback。下一步需要把两者组合成一个 Android-first 的移动端 AgentGUI,让同一账号下的手机可以连接用户自己的电脑并操作 Agent 会话。
这不是 Remote SSH、远程桌面或本机任意端口隧道。移动端消费现有公开 Agent/Workspace API,并以适合移动设备的方式呈现会话列表、对话流和 Composer。
本设计先在 Tutti Personal 验证。TSH/VM 在第二阶段接入同一移动端核心和 DeviceLink 基础设施,不建立另一套协议、会话实体或 UI 业务核心。
## 2. 目标
1. Android 手机与登录同一账号的 Tutti Personal 电脑完成显式配对。
2. 连接优先使用 P2P,失败时自动切换到 tsh-tunnel-relay。
3. 手机通过现有生成客户端、Agent Activity 和 Agent Host 完成会话读取与操作。
4. 移动端主视图为对话流,会话列表通过侧边抽屉打开。
5. Personal、TSH/VM 共用 canonical Agent 协议和 DeviceLink 核心。
6. Agent 数据保留在电脑端;控制面和 Relay 不持久化会话内容。
7. MVP 优先验证完整可用闭环,不被跨平台 UI 组件统一阻塞。
## 3. 非目标
MVP 不包含:
- Remote SSH、Terminal、远程桌面或任意 localhost 端口转发;
- iOS 真机验收、TestFlight、App Store 和 release 签名;
- 后台长期运行、系统推送和通知操作;
- 离线读取、离线写队列和会话内容持久缓存;
- 文件上传、图片输入、语音输入、富文本编辑器、`@` 引用选择器;
- 完整复制桌面右侧面板;
- TSH/VM 用户入口;
- Web 与 React Native 组件源码的全面统一;
- 长期多版本协议兼容矩阵;只保留 TSH 切换期间有界、可观测、可删除的临时 ALPN
seam。
## 4. 设计原则
### 4.1 一个事实,一个所有者
- Session、Turn、Interaction、Goal 和 runtime operation 的生命周期仍由 `packages/agent/host` 唯一定义。
- 移动端使用同一个 workspace `AgentSessionEngine` 语义,不创建 `MobileSession`、`RemoteSession` 或第二套会话状态机。
- `tsh-server` 只拥有账号、设备、配对、在线发现和短期 rendezvous 状态。
- DeviceLink 只拥有认证链路、product-neutral 双路径竞速和双向流,不解释 Agent
DTO;direct/Relay 身份认证与选路策略由产品 adapter 注入。
- React Native 只拥有导航、展示和设备本地的临时 UI 状态。
### 4.2 复用协议,不镜像页面
移动端不传输桌面 HTML,也不复制 DOM。它通过现有 Agent OpenAPI DTO、事件协议和生成客户端获取结构化数据,再使用 Native 组件渲染。
移动端可以暂时只展示完整协议中的一部分设置,但不得增加简化版创建接口或写死 Agent、模型、权限和运行模式。后续补齐 Composer 设置只能是增加 UI 暴露面,不应重做底层协议。
### 4.3 事件降低延迟,快照负责校准
事件流不是独立真相。进入 workspace、打开会话、事件缺口、网络重连和前台恢复都通过权威快照校准。手机不持久化可恢复的 Agent 状态机。
### 4.4 先闭环,再统一组件实现
MVP 从第一天复用行为、projection、状态语义和现有视觉语言。Web 与 Native 的 React 组件源码、图标实现和 token 物理来源可以在闭环验证后渐进统一。
## 5. 产品导航
Tutti Personal 移动端包含四个导航层级:
```text
登录页
└─ 设备选择页
└─ 会话列表页
└─ 会话详情页
```
规则:
- 未登录时停留在登录页。
- 完成登录前,登录页是唯一可用路由;登录后设备选择页是固定首屏。
- Device 是用户选择的远端电脑和 DeviceLink 连接上下文,因此保留独立页面。
- Tutti Personal 约定每台 Device 恰好包含一个 workspace。`workspaceId` 仍是所有
Agent 请求的 canonical scope,但不建立 workspace 选择路由;返回零个或多个
workspace 时连接失败并显示明确错误,不静默选择。
- TSH 的 `roomId` 在产品适配层一对一映射为 canonical `workspaceId`。
- 进入唯一 workspace 后先展示会话列表;只有用户选择会话或新建会话时才进入详情路由。
- 会话列表和会话详情是两个真实路由;会话详情顶部始终展示当前设备和 workspace。
- 会话详情路由的 `agentSessionId` 是详情导航的唯一所有者;workspace activity 只消费该
选择,不得在 reconcile 时静默切换详情路由。当前会话失效时返回会话列表。
- Android 返回键和边缘返回手势由原生导航栈处理:详情返回会话列表,会话列表返回
设备页并释放 DeviceLink,设备根页面再按系统规则退出。返回设备页时先同步失效旧
device/workspace scope,并在旧 DeviceLink 完成关闭前阻止新的连接操作。
会话列表复用桌面当前的 membership、分组、排序、置顶和运行状态语义,但布局与信息密度由移动端独立设计。MVP 不做跨设备聚合会话列表;每次只展示当前设备唯一 workspace 下的会话。
## 6. 系统结构
```text
apps/mobile (bare React Native)
-> @tutti-os/agent-gui/agent-conversation platform-neutral projection
-> @tutti-os/agent-activity-core
-> @tutti-os/client-tuttid-ts
-> injected DeviceLink fetch adapter
-> injected DeviceLink event socket adapter
-> Android TurboModule / JNI
-> packages/device-link Go core compiled as AAR
-> ICE + QUIC P2P
-> Personal host adapter
-> authenticated device lifecycle + managed Relay fallback
-> Tutti Desktop / tuttidd public Agent and Workspace API adapter
-> packages/agent/host
-> canonical local store and provider runtime
```
控制面:
```text
mobile account session
-> tsh-server device registry and pairing
-> short-lived rendezvous / candidates / online presence
-> direct DeviceLink or tsh-tunnel-relay
```
Agent 请求和响应不经过 tsh-server 业务存储。Relay 只转发已认证的加密字节流。
### 6.1 Mobile DI 和页面边界
Mobile 使用稳定 Bootstrap container。未登录 child 与登录后 child 互斥;账号变化
销毁并重建完整登录后子树。登录后 child 同时拥有账号会话、单个 DeviceLink、
动态 Agent Target 目录和 workspace catalog,不再增加语义重复的 Device child。
它下面最多持有一个 workspace child,后者拥有唯一
`AgentSessionEngine`、会话导航和进程内草稿。
`services/**` 是不依赖 React 的纯 TypeScript 服务。页面 binding 通过
`useSyncExternalStore` 订阅服务快照,props-only View 只持有抽屉、滚动、焦点、
键盘和 Interaction 输入等 UI 状态。数据加载、轮询、重连、DI scope、会话命令和
快照复制不得由页面 `useEffect` 驱动。
设备级快捷提示词由登录后的设备 scope 中的独立 service 读取,不进入 workspace
`AgentSessionEngine`、Session 或移动端持久存储。该 service 通过生成客户端读取电脑端
canonical 提示词顺序;Composer 的 `+` 菜单提供搜索与只读选择,点选后在当前输入位置
添加文本,不替换已有草稿,恢复输入焦点且不自动发送。
新增、编辑、删除和排序仍由电脑端负责。DeviceLink 只为此能力开放精确的快捷提示词
列表 GET 路由,不开放 mutation 或单条记录路由。
## 7. DeviceLink 边界
### 7.1 共享核心
新增一个先由 Personal 验证、随后再稳定为跨消费者边界的 `packages/device-link`:
- 从 TSH 现有生产实现提炼 Go ICE、STUN candidate、QUIC、证书 pinning、连接竞速、
负缓存,以及供产品 adapter 注入 direct/Relay 的生命周期 seam;
- 暴露认证后的双向流,不包含 Session、Turn、Composer 或 Workspace 业务实体;
- 提供 gomobile/AAR 构建入口;
- 在 Personal Desktop 和 Android 闭环验收后,成为 Tutti Desktop、TSH Desktop 和 Android 的共同源头。
不拆分 `device-link-core`、`device-link-protocol`、`device-link-react-native` 等额外包。平台桥接留在消费端:tuttid 负责 Desktop adapter,`apps/mobile/android` 负责 Android 生命周期和 TurboModule,TSH 负责其产品 adapter。
TSH 当前代码作为 donor implementation。Personal 验收前不要求 TSH 切换;稳定
发布后 TSH 才依赖上游版本并删除长期复制实现。
### 7.2 Android 形态
移动端采用 bare React Native,不依赖 Expo Go。Go 核心通过 gomobile 产出 AAR,Kotlin/TurboModule 层保持薄:
- 登录后配置设备身份和 rendezvous endpoint;
- 建立、关闭和观测一个活动 DeviceLink;
- 打开 request stream 和 event stream;
- 投影 Android 网络变化与前后台生命周期;
- 返回经过清洗的连接状态和错误分类。
ICE、QUIC、证书校验、连接竞速和探测退避不得在 Kotlin 中重写。direct/Relay
身份认证、fallback 延迟和错误分类仍由产品 adapter 决定,并通过共享 manager
提供的 dial、race 和 probe seam 接入。
### 7.3 业务传输
DeviceLink 不新增 Agent RPC,也不解释应用流 prelude;它只提供经过 peer
fingerprint 校验的通用双向字节流。Personal host adapter 定义极薄的 prelude:
- service kind:Agent HTTP request 或 Agent event stream;
- `protocolEpoch`;
- request/stream identity;
- 已认证 peer identity,由 host adapter 在建立 link 时强制传入,并交给 Desktop handler。
HTTP 流携带 fetch 所需的 method、public API path、headers 和 body;Desktop adapter 将其交给公开 Agent/Workspace API handler。事件流承载现有事件帧。它不是任意 TCP 代理,也不能选择任意 localhost 端口。
TypeScript 侧继续使用:
- `createTuttidClient({ fetch })` 注入 DeviceLink fetch;
- 事件客户端的 `webSocketFactory` 注入 DeviceLink event socket;
- 概念 URL `tutti-device://<desktopDeviceId>` 只用于路由到当前设备,不成为新业务 API。
### 7.4 P2P 与 Relay
- P2P 与 Relay 同时受同一设备身份和配对授权约束。
- Mobile 的 Agent HTTP/live 数据流 direct 与 Relay 同时立即开始,首个成功的
authenticated stream 获胜;TSH Desktop 的默认 3 秒 Relay 兜底窗口由其产品策略
保持不变。
- 第一条完成认证的可用链路获胜,晚到链路关闭。
- 主界面和连接详情继续复用现有状态与 path scope,不新增 P2P/Relay 展示分支;选路
只在 native transport 内部生效。
- 具体选路、耗时和 fallback reason 只进入清洗后的诊断与指标,不记录 candidate、IP、token 或 Agent payload。
## 8. 账号、配对和设备身份
### 8.1 同账号约束
Mobile、Tutti Personal 和 TSH 使用同一 tsh-server 账号体系。Tutti Personal 只有在登录功能开启且用户已登录时,才能开启移动端连接能力。
“同账号”以账号服务返回的 canonical `user_id` 为准,不以界面展示的邮箱字符串
判断。GitHub 登录与邮箱验证码登录即使展示相同邮箱,也可能对应不同账号 identity;
Mobile 应优先允许用户复用 Desktop 的登录方式,不能通过邮箱相等放宽配对校验。
Mobile GitHub 登录直接打开现有 Web 登录页,通过只监听 `127.0.0.1` 的短时 bridge
接收一次性 transfer code,再复用账号服务兑换会话;App 不接触 GitHub 凭据或网页
Cookie,也不新增移动端账号实体。Android 在浏览器 provider 支持时使用 Auth Tab,
不支持时降级到外部系统浏览器;iOS 使用 `ASWebAuthenticationSession`。平台认证会话
只负责 callback 匹配与回到前台,不拥有账号 session 或 provider OAuth 流程。
配对必须同时满足:
1. 手机拥有有效账号登录态;
2. Desktop 拥有同一账号的有效登录态;
3. Desktop 显式打开连接手机流程;
4. 手机扫描短时一次性 QR challenge;
5. tsh-server 校验同账号和 challenge 状态;
6. Desktop 显式确认配对。
Desktop 的账号登录入口与手机远程访问入口独立发布。手机远程访问当前已停用;
`mobile.remoteAccessSettings` 仅作为旧 profile 的持久化兼容键保留,desktop daemon
会强制忽略它,不启动 owner-side discovery,也不提供手机配对 UI。「连接」页仍承载
账号登录、退出和刷新。该开关不创建第二套登录态、设备实体或协议能力。
QR 不包含 bearer token、私钥、原始候选或可长期使用的连接凭据。
### 8.2 持久设备身份
- Mobile 和 Desktop 各自生成持久设备密钥;公开测试前私钥只保存在设备安全存储中。
- tsh-server 持久化账号绑定的设备公钥、设备元数据、配对关系和撤销状态。
- 短期 link identity 必须与已配对设备身份绑定,继续复用 DeviceLink 的 QUIC 双向认证和 pinning。
- 手机退出登录会立即断开并清除账号 token,但不自动撤销配对。
- 再次登录同一账号可以恢复配对;其他账号看不到且不能使用该配对。
- 主动移除设备才撤销配对。撤销必须让新连接和已建立连接上的后续业务调用失效。
MVP 不增加生物识别二次确认。后续可在不改变 DeviceLink 业务协议的前提下增加本机安全门。
Personal daemon 的第一阶段实现先把 Ed25519 私钥保存在 daemon state root 下权限为
`0600` 的独立文件中,且只向 service 层返回签名能力;本地 HTTP API 不返回私钥、
账号 cookie 或签名原文。这是端到端联调前的临时落地方式,不改变设备 identity
协议;公开测试前必须迁移到系统安全存储。Android 从首次实现起使用 Keystore。
### 8.3 控制面存储
MySQL 持久化:
- 设备公开身份和账号归属;
- 配对关系;
- 撤销状态和必要的审计时间。
Redis 短期保存:
- 一次性配对 challenge;
- DeviceLink attempt;
- candidate 与短期 link material;
- 设备在线状态和 Relay/rendezvous 协调信息。
Redis TTL 不因普通 candidate 更新无限延长。Agent 会话、消息、Interaction payload 和 Composer 内容不得进入这些存储。
### 8.4 M2 最小持久模型(已确认并实现服务端基础)
现有 `user_device` 已经是同账号 canonical 设备实体,保存名称、平台、版本和最近
出现时间;M2 直接让它同时拥有设备的公开身份,不再创建一张一对一的
`user_device_identity` 表。现有 `device_authority` 继续只负责 Desktop
runtime/Relay 路由,不能拿来代表 Mobile 或配对关系。
M2 建议只新增一张表:
1. 扩展现有 `user_device`
- 增加公钥算法、公钥、key fingerprint、identity revision 和撤销时间;
- 私钥永不上传;更换设备公钥必须递增 revision,并让旧配对失效;
- 没有注册公开身份的历史设备仍可展示,但不能参与 Mobile 配对或 DeviceLink。
2. 新增 `device_pairing`
- 保存 `pairing_id`、`user_id`、controller `user_device_id`、target `user_device_id`、状态、revision、确认和撤销时间;
- controller 是 Mobile,target 是 Desktop;两端必须属于同一 `user_id`;
- 同一有向设备对只有一条记录,重新配对更新该记录而不是制造第二套关系实体。
一次性 QR challenge 仍只进入 Redis:记录 challenge ID、同账号 user ID、target
device、claiming controller device、随机 nonce 的摘要、状态和固定过期时间。Desktop
创建 challenge,Mobile claim,Desktop confirm 后才在 MySQL 激活 pairing;claim、
confirm 和 revoke 都必须幂等。QR 本身不携带账号 token、私钥、公钥私密材料、
candidate 或长期授权。
DeviceLink attempt 继续使用同一短期实体和 Redis repository。内部把当前
`roomId` key 泛化为 `scopeKind + scopeId`:TSH 使用 `room` scope,Mobile Agent
channel 使用 `paired_device` scope。HTTP 可以保留产品适配入口,但不能复制一份
Mobile attempt model、candidate 校验或 ready 状态机。
该 schema 已由用户明确确认。`tsh-server` 已实现 additive migration、设备公开身份
注册、五分钟一次性 challenge、双端 Ed25519 签名、配对列表/撤销,以及
`room | paired_device` 共用的 DeviceLink attempt scope。短期 DeviceLink 指纹还会由
配对设备的持久 Ed25519 identity 签名,撤销 pairing 后 attempt 访问立即失败关闭。
配对 proof 的 canonical bytes 是:
```text
tutti-device-pairing/1\n<claim|confirm>\n<challengeId>\n<base64urlSecret>
```
paired-device DeviceLink proof 的 canonical bytes 是:
```text
tutti-device-link/1\ncreate\n<pairingId>\n\n<ephemeralFingerprint>
tutti-device-link/1\nupdate\n<pairingId>\n<attemptId>\n<ephemeralFingerprint>
```
## 9. Workspace 和产品适配
移动端 canonical identity 始终是 `workspaceId`:
- Tutti Personal:一台设备当前只有一个 workspace,仍返回真实 `workspaceId`,不省略此层实体;
- TSH/VM:adapter 将 `roomId` 一对一映射为 `workspaceId`;
- 零个 workspace 显示空态;一个自动进入;多个展示独立选择页。
Personal 和 VM 的差异通过服务端实际 capability/catalog 表达。移动端不得根据 provider 名称或硬编码的产品枚举推断 Agent 行为。Capability 只描述功能可用性,不承担前期版本兼容。
## 10. Agent 数据与同步
### 10.1 初始读取与实时更新
1. 进入 workspace 时获取会话列表快照。
2. 打开会话时获取完整 session snapshot。
3. 在线期间通过现有 Agent Activity 事件流增量更新。
4. 连续且版本完整的 message update 可以直接合并。
5. 事件缺口、重连、前台恢复和关键状态变化触发权威读取。
6. 重连成功后先校准,再恢复交互。
Mobile 不建立 SQLite 会话库。允许保存的设备本地状态仅包括:
- Android Keystore 中的设备私钥;
- 配对设备标识;
- 上次选择的设备、workspace 和 session;
- 当前进程内的会话快照与 per-session 草稿。
### 10.2 并发和幂等
Desktop 与 Mobile 可以同时打开并操作同一 Session。Host 继续负责 canonical winner 和 lifecycle transition。
- create/send 使用稳定 `clientSubmitId`;
- Interaction response 使用准确的 `(workspaceId, agentSessionId, turnId, requestId)`;
- 超时后先读取 authoritative state,再决定是否用相同 identity 重试;
- 不盲目重放 delivery state 不明确的操作;
- 手机草稿和本地 prompt queue 不跨设备同步。
## 11. AgentGUI 与 React Native
### 11.1 包内分层
不新增 `agent-conversation-core` 包。在现有 `@tutti-os/agent-gui` 内整理清晰子路径:
```text
@tutti-os/agent-gui/agent-conversation
platform-neutral projection, reconciliation, state and interaction model
@tutti-os/agent-gui/agent-conversation/web
DOM implementation
@tutti-os/agent-gui/agent-conversation/native
React Native implementation
```
平台无关入口的依赖图不得包含 DOM、CSS、Monaco 或 `react-dom`。它继续消费 canonical Agent Activity 类型,不定义新的 Conversation DTO。
### 11.2 共享边界
会话列表属于产品壳层。Web 与 Native 复用数据、排序和状态语义,但允许不同布局和导航。
对话流属于核心体验。Web 与 Native 应保持同一视觉语言和交互语义,覆盖:
- user/assistant message;
- Reasoning;
- Tool Call;
- Approval;
- Question;
- Plan;
- Processing;
- unsupported content fallback。
MVP 优先共享 conversation projection、稳定 row identity、相邻消息合并、工具分组、processing row、pending interaction 和 reconcile 行为。Web 与 Native 分别实现虚拟列表、Markdown、代码块、菜单、Bottom Sheet 和平台 primitive。
### 11.3 UI System 节奏
现有 `@tutti-os/ui-system` 是 DOM/CSS 实现,不能直接作为 React Native 组件库。MVP 在 `apps/mobile` 内建立薄 Native theme mapping,名称和取值对齐现有 semantic token,不创建另一套设计语言。
闭环验证后再把必要颜色、字号、间距、圆角和状态 token 提炼为 `@tutti-os/ui-system` 的平台无关导出,并逐步收敛真正可共用的 Conversation 组件。该重构不是 MVP 链路前置条件。
所有移动端文案仍进入 i18n。不得在 Native 组件中硬编码用户可见文案。
### 11.4 会话详情展示节奏
Native Mobile 会话详情保持正常时间顺序,不在展示层重组 Turn 或推断新的
Interaction 状态。页面 Header 使用两层信息展示会话标题与现有 workspace/device
上下文;Transcript 在宽屏上限制阅读宽度,用户消息使用紧凑气泡,Assistant 正文保持
平面排版,Reasoning、Tool、Processing 和文件摘要使用低权重的渐进披露样式。
会话首次进入时在最终布局帧跟随最新消息;用户已离开底部并触摸历史内容时,应先退出
follow-end,再处理 Reasoning、Tool 或文件摘要的展开,避免内容高度变化把阅读位置强制跳回
末尾。离开底部后保留明确的“回到底部”动作。
Pending Interaction 是 Composer 前的 normal-flow sibling,并且仍是当前 exact
Interaction 的唯一 actionable 挂载点。多个 pending 项在有限高度的独立滚动区中保持
canonical 顺序和 exact identity;页面不把它复制为第二张 transcript 操作卡,也不使用
absolute overlay,也不参与 Transcript 的滚动锚点或 follow-end 几何计算。Composer 使用单一 token-backed surface
组织草稿、现有设置入口和 Send/Stop action,继续服从原有 Engine projection、草稿、
readiness 和 overlay activation fence,不在 Native presentation 中重解释业务状态。
窄屏上的 Composer 设置入口允许水平滚动,并使用跨平台边缘方向提示表达仍有更多设置
(Android 同时保留原生渐隐);键盘弹起时
Composer 整体上移,Transcript 继续保留可阅读区域。
## 12. Composer
MVP 支持:
- 多行纯文本;
- 发送、停止和失败重试;
- 每个 session 的进程内草稿;
- 软键盘、输入区高度和滚动到底部;
- 新建会话并发送首条消息。
新会话使用现有完整 create/send/settings contracts。MVP UI 默认继承 workspace 返回的 Agent、模型、权限和其他 Composer 默认设置。缺少必要默认项时明确要求选择,不静默失败。
后续模型、推理强度、权限、运行模式、Agent Target 和其他设置读取并修改同一份 workspace defaults 与 session settings。Mobile 不保存独立偏好,也不新增 mobile-specific DTO。Personal 与 VM 的可选项来自动态 capability/catalog,不写死枚举。
## 13. 连接生命周期
- Mobile application service 只消费平台原生提供的进程级前后台信号。
Android 由 `ProcessLifecycleOwner` 投影,iOS 由 `UIApplication` 投影;
React Activity 暂停、同进程扫码 Activity、授权 Activity 等页面切换不得解释为
App 进入后台。配对、DeviceLink 和会话服务不感知平台 Activity 类型。
- 前台保持实时连接。
- 进入后台后只做短时 best-effort 保活,随后主动断开;Android 系统可更早终止连接。
- MVP 不使用常驻前台服务或推送唤醒。
- 回到前台重新发现设备、建立链路、校准 workspace/session snapshot 并恢复订阅。
- 重连期间保留当前内存画面,禁用发送、取消和 Interaction 操作,不建立离线写队列。
- 设备离线时保留当前画面,并提供重试和返回设备列表。
- Desktop Electron 窗口关闭不影响远程能力;只要 tuttidd/desktop daemon 在线且用户未暂停远程连接,手机可以连接。
- “暂停远程连接”和“撤销配对”是不同操作。
## 14. 版本策略
前期只支持单一当前移动端协议,不维护兼容矩阵。
- 应用流 prelude 包含简单 `protocolEpoch`;
- 非破坏性变化沿用当前 epoch;
- 任何破坏性变化直接递增 epoch;
- 两端 epoch 不一致时拒绝应用连接并提示升级对应客户端;
- 不维护旧 DTO、字段迁移、双写或兼容分支;
- 等用户规模和发布稳定性需要时,再设计兼容窗口、最低版本和灰度策略。
现有 TSH DeviceLink transport wire version 的迁移是传输实现问题,不扩展为 Mobile Agent API 的多版本兼容承诺。
## 15. 仓库职责
| 仓库 | 职责 |
| ------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `tutti` | `packages/device-link`、tuttid Personal adapter、AgentGUI 平台无关边界、React Native App、生成客户端和 Host 集成 |
| `tsh` | 作为现有 DeviceLink donor;升级消费 Tutti 上游包;第二阶段提供 room/workspace 与 VM 产品 adapter |
| `tsh-server` | 账号、设备注册、扫码配对、设备在线状态、rendezvous、STUN endpoint、短期 Relay 授权 |
| `tsh-tunnel-relay` | workspace-less paired-device Agent channel 的认证与不透明转发;不解析 Agent payload |
所有 Agent lifecycle 缺口先进入 `packages/agent/host` 和 conformance scenario。tuttid 与 TSH 只编写授权、HTTP/query、产品策略和传输 adapter。
## 16. 实施阶段与任务
### M0 — 契约和 Android 链路 spike
归属:`tutti`。
- 用 provisional API 验证 `packages/device-link` 的最小传输能力;不在 M0 锁定稳定公开 API。
- 从 TSH 提取最小 ICE/QUIC vertical slice。
- 产出 Android arm64 AAR,并通过薄 JNI/TurboModule 建立双向测试流。
- 验证 Android 系统路由、Wi-Fi/蜂窝切换、VPN/TUN 环境的失败分类。
- 固定 `protocolEpoch` mismatch 的 fail-fast 行为。
完成条件:Android 与 Personal macOS adapter 可以通过同一核心建立经过注册设备
身份约束的双向流;产品桥接不直接暴露任意 TLS/QUIC dial/listen;不存在 Kotlin
重写 ICE/QUIC 的需求。
当前进度:共享模块、Go/Java binding 边界、host loopback probe、Android
交叉编译、四 ABI AAR 和签名探针 APK 均已完成;探针已在 Android 15 ARM64
emulator 上跑通 ICE -> pinned QUIC -> 双向 stream echo。`authenticated`
facade 和 gomobile Link/Stream API 已统一 ICE、peer fingerprint pinning、QUIC、
connect/cancel/close 顺序,并由 Desktop/Android adapter 共同消费。物理设备上的
Wi-Fi/蜂窝切换和 VPN/TUN 失败分类仍待验证。
### M1 — Personal authenticated DeviceLink adapter
归属:`tutti`。
- 搬迁并清理 production DeviceLink、ICE/QUIC、连接竞速、负缓存和诊断。
- 在 Tutti 建立 Go 单测、跨编译、gomobile binding 和 AAR consumer 构建。
- 为 Personal Desktop 和 Android 定义相同的 authenticated link 生命周期 port。
- 强制调用方提供预期 peer identity/fingerprint,由 facade 统一拥有 ICE -> pinned
TLS -> QUIC -> close/cancel 顺序。
完成条件:Personal Desktop 与 Android 真机通过同一 facade 建立链路,原始
`QUICEndpoint` 不进入产品桥接;TSH 仍保持现状,不阻塞 Personal 验证。
当前进度:共享 facade、Desktop owner host、Android caller bridge、真实
authenticated stream 集成测试,以及代际隔离、连接池、建连去重、连接竞速和探测
退避的 product-neutral manager 均已完成;Tutti Desktop `mobileremote` owner
已经作为首个 adapter 接管共享 manager 和 `candidateexchange`;Android/iOS 通过
同一 Go coordinator 的 gomobile start、next/resolve action、notify 与 stop/cancel
边界接入。初始 ICE 凭据允许携带空 candidate 立即发布,双方在 `Connect` 运行期间继续
增量交换 candidate,不再串行等待两轮 STUN gathering;retry snapshot、500ms poll
timer 与 worker lifecycle 不再由 TypeScript 维护第二份,服务端返回的权威 attempt
确认 candidate 已落库后才完成 publication ACK。TSH adapter 切换和真机跨网络验证仍待
完成。Relay 身份认证、控制面、fallback 产品策略和 TSH 产品诊断继续由消费端拥有。
### M2 — 设备、配对和控制面
归属:`tsh-server`、`tutti`。
- 增加持久设备 identity/pairing/revocation schema。
- 增加一次性 QR challenge 和 Desktop confirm 流程。
- 将现有 room-coupled attempt 泛化为 paired-device scope,同时保留 TSH room lane。
- 复用 DeviceAuthority、presence、Redis TTL、candidate bounds、rate limits 和 STUN endpoint。
- Tutti Personal daemon 注册设备在线状态,并提供开启、暂停和撤销入口。
完成条件:同账号手机与 Desktop 完成配对;跨账号、过期 challenge、重复使用和撤销均失败关闭。
当前进度:`tsh-server` 的 schema、HTTP contract、Redis challenge、签名校验、
pairing lifecycle 和 paired-device rendezvous 已完成并部署。Tutti Personal daemon
已复用 daemon-wide stable device id,增加持久 Ed25519 identity、账号 cookie
控制面 adapter,以及 start/status/confirm/list/revoke 本地 API;challenge secret
不会持久化,Desktop renderer 只在配对界面打开期间临时持有二维码 payload。
Desktop UI 已接入 QR 创建、配对码显示/复制、状态轮询、自动确认和撤销;Android
已接入平台原生浏览器认证 GitHub 登录、邮箱验证码登录、Keystore 设备身份、内置 ZXing
扫码/手动粘贴配对码、
challenge claim/poll,并只展示属于当前 Mobile identity 的配对设备。
真实账号二维码联调仍是 M2 的剩余端到端完成条件。
### M3 — Relay 和 Agent API transport
归属:`tsh-tunnel-relay`、`tutti`。
- Relay 增加 paired-device Agent service channel,不复用只读 query channel 冒充控制流。
- 实现 application stream prelude、Desktop allowlist handler 和 peer authorization。
- 实现 DeviceLink fetch adapter 和 event socket adapter。
- 使用现有生成客户端验证 list/get/create/send/cancel/settings/interaction/event 路径。
- 增加断线、ambiguous delivery 和 snapshot reconcile 测试。
完成条件:不增加 Agent DTO 即可通过 direct 和 Relay 完成同一套 Agent API 请求与事件订阅。
当前进度:direct lane 已实现 application epoch/length frame、Desktop route
allowlist 和 Android fetch adapter,并直接复用生成的
`@tutti-os/client-tuttid-ts`;workspace、Agent Target catalog、Session
list/get/create/send/cancel/Interaction 均沿用现有 HTTP contract。owner host 会在
caller 获得 response-only STUN endpoints、替换临时 Participant 并立即二次发布 ICE
凭据后再认领 attempt,避免连接旧 fingerprint;该初始快照可以没有 candidate。
owner 与 caller 随后在 `Connect` 已运行时通过权威 attempt 快照增量补齐 candidate,
WebSocket 只负责唤醒,丢失时继续使用 500ms HTTP 校准。
Relay Agent lane 已接入同一套应用帧协议:Desktop `mobileremote` 只在用户打开
移动端连接开关后获取 Relay owner demand,按 Device Authority 完成 identity
enrollment、owner token 和 lease renewal;Relay stream prelude 校验 authority、
user、target、channel 及 paired-device scope 后复用现有 Agent HTTP/live handler。
Mobile 在直连准备阶段并行请求短期 Relay descriptor;原生层对每条 direct/Relay
数据流先完成共享 DeviceLink transport probe,收到对端 ACK 后才让该路径赢得竞速,
再发送应用帧;不会取消 direct attempt,后者继续到成功或当前连接代际失效。
Android/iOS native adapter 不能把 WebSocket 101 或本地 QUIC stream allocation 单独
当作成功路径。因此 UI、生成客户端和 Agent DTO 不变。paired-device attempt 的
`device_link.attempt.changed` 通过已上线的设备级 V2 WebSocket 作为唤醒提示,连接
建立时携带 session cookie 与 `deviceId`;客户端收到提示后仍通过 HTTP attempt API
读取权威状态,推送丢失、重连或乱序时回退到 500ms 状态轮询。服务端对没有 room 的
paired-device attempt 使用 `userId + deviceId` 精确投递;未开启连接功能的 Desktop
不会建立这条长连接。pairing 的 active 快照仍在本地使用当前 2 秒周期轮询,Relay
stream 在本地快照中找不到 pairing 时 fail closed。
这里依赖的 Relay descriptor、Device Authority 和 `tsh-tunnel-relay` Agent channel
是跨仓库的增量契约;服务端部署状态不在 Tutti 仓库内,须以对应 tsh-server/relay
变更合入和线上配置为准。direct lane 的 `agent_live` event stream 已接入:delta、
Turn、Interaction 和 session audit 通过 framed live protocol 进入 Mobile;
canonical-only 更新由 Personal 的 `mobileremote` adapter 转为 scoped discontinuity,
再触发权威 snapshot reconcile。Session/Message poller 仅在 event stream 未就绪或
断开时作为降级路径。
### M4 — Mobile App shell(Android 首发)
归属:`tutti`。
- 建立 `apps/mobile` bare React Native 工程,Android 首发并保留未来 iOS 目录能力。
- 接入账号登录、Android Keystore、设备列表、Personal 单 workspace 校验和导航恢复。
- 接入前后台连接生命周期、连接状态和错误页。
- 使用原生导航栈实现登录门禁、设备根页面和 Android 系统返回。
- 建立移动端 i18n 与 Native theme mapping。
完成条件:登录、配对、选设备、解析唯一 workspace、重连和撤销形成完整非 Agent UI 闭环。
当前进度:bare React Native 0.86 Android 工程、Auth Tab(不支持时降级系统浏览器)
统一 Tutti 账号登录入口(具体登录方式由托管登录页提供)、Keystore Ed25519 identity、
扫码/粘贴配对码、配对设备列表、Native DeviceLink bridge、移动端 i18n
和 semantic theme mapping 已完成。账号、设备、workspace 和前后台生命周期已迁入
纯 TypeScript DI service;页面只保留 binding 与 Native presentation。登录、
设备、会话列表和会话详情使用类型安全的原生导航栈;连接 Device 后会校验并打开
唯一 workspace,不建立 workspace 选择页。Native 15 秒后台 grace 后断开也已接入。当前最低版本为 Android 13/API 33,
以使用系统 Ed25519 provider;prepare/connect 使用 native generation token
隔离取消后晚到的连接任务。TypeScript/Jest、Metro、Kotlin/Java/CMake、
四 ABI APK 构建,以及 Android 15 ARM64 模拟器安装启动均通过;前台自动重连和
撤销专用状态仍待完成。
iOS Simulator host 已在相同 `apps/mobile` application core 上建立。它复用全部
TypeScript service、AgentGUI projection、Native renderer 和 Composer,只实现
`TuttiMobileSecurity` / `TuttiDeviceLink` 平台 port。当前 iOS adapter 包含
Keychain/CryptoKit identity、账号 session/Cookie、`ASWebAuthenticationSession`、
localhost browser login bridge、
AVFoundation scanner、gomobile XCFramework、Agent HTTP/live framing 和前后台 grace;
Simulator 的相机路径明确使用现有手动配对码降级。真机与分发仍不属于本阶段完成条件。
### M5 — AgentGUI MVP
归属:`tutti`。
- 清理 `agent-conversation` 平台无关导出,阻止 DOM/Monaco 依赖进入 Native bundle。
- 接入 workspace `AgentSessionEngine` 和 authoritative snapshot/event reconcile。
- 实现独立会话列表页、默认会话选择、新建和切换。
- 实现 Message、Reasoning、Tool、Approval、Question、Plan、Processing 和 unsupported fallback。
- 实现纯文本 Composer、发送、停止、重试和 per-session 内存草稿。
- 验证 Desktop 与 Mobile 同时操作及请求幂等。
完成条件:达到第 17 节所有产品闭环验收项。
当前进度:独立会话列表页、动态 Agent Target 新建会话、切换、canonical message
增量读取、纯文本 Composer、发送、停止,以及 Approval/Question/Plan
Interaction 提交已接入。Mobile 已接入 workspace `AgentSessionEngine`;create、
send、stop 和 Interaction response 统一通过 Engine intent/command port,Session
和 Message 快照映射后进入同一 canonical state。Desktop 与 Mobile 的生成契约
映射、Session detail 聚合和事件 reconcile 判定分别由
`@tutti-os/agent-activity-tuttid-adapter` 与
`@tutti-os/agent-activity-core` 复用;Mobile Rail 只保存分组、Session id、游标
和总数,实体页瞬时映射后直接 upsert 到 Engine。Interaction 提交中、失败状态和
per-Session runtime availability 也由 Engine 投影,Native card 不维护第二套
Promise 状态。当前 Native renderer 是
`apps/mobile` 内的 MVP presentation adapter;它不定义 Session/Message DTO。
Interaction answer payload 与原型安全的 question-id 读写已从
`@tutti-os/agent-gui` 的无 DOM 子路径复用;轮询为 single-flight,超时重试保留
原始 session/submit identity;不明确的写入会先做 workspace 权威校准,再使用同一
identity 进入显式 Retry。Markdown/code、unsupported fallback 的完整视觉语义和
会话列表视觉对齐仍是剩余项。
### M6 — 稳定性和第二阶段准备
归属:全部相关仓库。
- 完成局域网、不同 cone NAT、对称 NAT/CGNAT 和 Relay 故障注入。
- 完成前后台、睡眠唤醒、网络切换、daemon 重启和事件缺口测试。
- 清洗指标与诊断,确认无 IP、candidate、token 和 Agent payload 泄露。
- 根据验证结果决定 UI token 提炼和共享组件的下一批范围。
- 已冻结最小 transport API;每次启用稳定 Go tag 前,在 release head 重跑可复现
AAR consumer gate。真实网络切换和 Relay 验收作为后续 hardening,不改变该公开边界。
- 再安排 TSH 切换上游依赖、删除复制实现并启用 VM/room workspace lane。
### M7 — iOS Simulator 功能对齐
归属:`tutti`。
- 从同一 React Native application core 建立 iOS 15.1+ shell,不复制对话流或业务状态机。
- 为 DeviceLink 与 Agent live mobile packages 生成 device + Simulator XCFramework。
- 以同名 Native Module contract 实现 Keychain identity/session、Cookie、browser
login bridge、扫码、DeviceLink request/live stream 和 lifecycle grace。
- Simulator 使用手动配对码覆盖无相机环境;硬件能力不得返回伪成功。
- 通过 iOS Metro bundle、TypeScript/Jest、ObjC binding、Pods 和 Simulator build。
完成条件:Simulator 可以启动同一 App、完成非相机入口的登录/配对/连接和 Agent
对话闭环;真实相机、蜂窝/VPN/后台稳定性、签名和 TestFlight/App Store 留到 iOS
真机验收阶段。
当前进度:iOS shell、Native Module adapter、Go XCFramework build boundary 和共享
TypeScript 平台清理已完成;Metro iOS production bundle、TypeScript/Jest、device +
Simulator XCFramework、Pods 和 generic Simulator build 已通过。App 已安装并启动于
iPhone 17 Pro / iOS 26.5 Simulator,Hermes 成功执行共享 JavaScript bundle;真实账号
登录、手动配对码、DeviceLink 和 Agent 对话闭环仍需在 Simulator 做带账号的验收。
## 17. Android Personal MVP 验收
1. Android 登录后可以通过二维码与同账号 Desktop 完成配对。
2. 单设备、单 workspace 自动进入上次会话。
3. 可以查看会话列表、新建会话、切换会话。
4. 消息与 Agent Activity 流式更新,重连后无重复或永久缺口。
5. 可以发送、停止、重试,并完成 Approval、Question 和 Plan 等阻塞性交互。
6. Desktop 窗口关闭但 daemon 在线时仍可操作。
7. Desktop 与 Mobile 同时操作同一会话不产生重复提交或状态分叉。
8. 同局域网和不同普通 NAT 验证 P2P;无法打洞时自动回落 Relay。
9. 前后台切换后可以重新连接并用快照校准。
10. 设备离线、配对撤销和 `protocolEpoch` 不匹配有明确错误状态。
11. tsh-server 和 Relay 不持久化 Agent 会话内容。
12. 未识别内容使用显式 unsupported fallback,不导致整个对话崩溃。
P2P 成功率不是单独的发布门槛。发布目标是 direct 优先且 Relay 能保证可用。
## 18. 风险与缓解
| 风险 | 影响 | 缓解 |
| ----------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| gomobile、Pion ICE 与 quic-go 在 Android 真机行为不同 | 核心链路不可用或不稳定 | M0 先做 vertical slice;在 UI 投入前完成跨网络真机测试 |
| Android 网络/VPN 路由与 macOS 物理接口策略不同 | candidate 不可达或错误选路 | Android 默认使用 `system` 的共享 LAN-first host priority,并保留 server-reflexive 公网 fallback;Android Network 绑定与 VPN/TUN 单独验收 |
| 直接导入 AgentGUI 带入 DOM/Monaco | Metro 构建失败或 bundle 膨胀 | 平台无关 subpath 建立严格 import boundary 和检查 |
| Relay 当前 channel 只覆盖既有 query lane | 写操作被错误复用或授权过宽 | 增加明确 paired-device Agent channel,复用身份校验但不复用业务含义 |
| App 与 Desktop 快速迭代产生协议漂移 | 无法连接 | 单一 `protocolEpoch` fail-fast,并协调开发期发布,不维护兼容分支 |
| Desktop/Mobile 并发导致 ambiguous delivery | 重复消息或重复响应 | 稳定 submit/request identity;超时后先读权威状态再重试 |
| UI 跨平台统一范围过大 | 阻塞 MVP | 首批共享 projection 与行为;Native renderer 独立;验证后再提炼 token/组件 |
| 控制面意外记录敏感数据 | 隐私泄露 | schema、日志和指标只允许身份/分类信息;Agent payload 保持端到端数据面 |
## 19. 后续演进
MVP 稳定后按真实使用反馈推进:
1. 补齐 Composer 模型、推理、权限、运行模式和 Agent Target 设置。
2. 增加附件、图片、引用和 richer Markdown/code rendering。
3. 将必要 semantic token 提炼到 `@tutti-os/ui-system` 平台无关出口。
4. 渐进共享 Conversation component source,保留 `.web.tsx` / `.native.tsx` primitive。
5. 在同一 App 核心启用 TSH/VM workspace/room lane。
6. 完成 iOS 真机联调、签名与可选的 TestFlight/App Store 分发。
7. 用户规模和发布节奏稳定后,再设计协议兼容窗口、推送与后台能力。
@@ -0,0 +1,649 @@
# Agent Session Fork Design
Status: superseded by the 2026-07-29 optimistic through-turn Fork contract
This document is retained as historical design context and is no longer the
implementation contract. The current contract lives in
`docs/architecture/agent-gui-node.md` and `packages/agent/host/README.md`: only
the selected canonical Turn's provider binding is verified; historical prefix
state is trusted; the source remains writable; provider Fork precedes local
materialization; unknown delivery is never redispatched; and no legacy
compatibility path is exposed.
Date: 2026-07-27
Supersedes:
- [2026-07-01 Agent Session Fork Design](./2026-07-01-agent-session-fork-design.md)
- [2026-07-01 Agent Session Fork Implementation Plan](./2026-07-01-agent-session-fork-plan.md)
## 1. 结论
AgentGUI Fork 是 Agent Host 拥有的一等 Session 生命周期能力,不是 GUI 复制消息,也不是
tuttid service 拼装 create + replay。
当前版本完整实现 `throughTurn`:
- 用户选择一个已 settled 的 canonical Turn;
- 新会话包含该 Turn 及其之前的已稳定历史;
- provider 使用原生 Fork 建立独立推理上下文;
- Store 从 prepare 时冻结的 snapshot 原子创建独立 canonical root Session;
- provider 不支持时,确切 Session 的 Turn 级 Fork 能力为 false,GUI 不展示按钮。
统一基建已经保留两个结构能力:
```text
fork // 完整会话 Fork
forkThroughTurn // 包含指定 Turn 的 Fork
```
本期只有 `forkThroughTurn` 具备端到端实现。`fork` 字段和统一 `ForkPoint` 已进入各层契约,
但完整会话入口、Host 分支和 provider 接入留到后续,不能仅把 capability 改为 true。
### 1.1 整体工作流程
![Agent Session Fork 整体工作流程](./assets/agent-session-fork-workflow.png)
### 1.2 模块架构
![Agent Session Fork 模块架构](./assets/agent-session-fork-module-architecture.png)
## 2. 架构所有权
```text
AgentGUI Turn action
-> AgentSessionEngine mutation
-> AgentActivityAdapter
-> generated tuttid client
-> tuttid API/service adapter
-> Agent Host durable Fork saga
-> store-sqlite snapshot/operation/commit
-> provider-neutral runtime Fork
- Codex app-server thread/fork(lastTurnId)
- Tutti Agent app-server thread/fork(lastTurnId)
- Claude Agent SDK forkSession(upToMessageId, title)
```
各层职责:
| 层 | 职责 |
| ------------------ | -------------------------------------------------------------------- |
| AgentGUI | 展示能力、提交 canonical boundary、展示 pending/lineage、成功后导航 |
| AgentSessionEngine | 唯一前端 mutation 状态源,生成并复用 request/target identity |
| Activity adapter | 映射 typed operation,并通过 GET 调和 accepted operation |
| tuttid API/service | 鉴权、HTTP 状态、DTO 映射;不拥有 Fork saga |
| Agent Host | 校验、序列化、runtime preparation、dispatch、恢复和提交 |
| store-sqlite | source fence、snapshot、operation、ID remap、canonical atomic commit |
| daemon runtime | 按确切 provider runtime 解析能力并调用 provider Driver |
| Codex Driver | `thread/fork`、版本门槛、完整 provider Turn prefix 验证 |
| Tutti Agent Driver | 复用 app-server Fork 协议,使用独立版本门槛和 `TUTTI_AGENT_HOME` |
| Claude SDK Driver | stateless inspect/fork、消息 checkpoint、child UUID 映射与复验 |
这符合 AgentGUI 的既有约束:canonical lifecycle 和 mutation 状态属于 Activity Engine;
React 不保存第二份 Fork request、pending 或结果状态。
## 3. 统一领域模型
### 3.1 Fork Point
Host 使用显式 Point,而不是为每种 Fork 建独立命令:
```go
type SessionForkPoint struct {
Kind SessionForkPointKind
TurnID string
}
const SessionForkPointThroughTurn = "through_turn"
```
Store operation 持久化 `point_kind` 和 `source_turn_id`。v1 operation 的迁移语义明确为
`through_turn`,不会在恢复时根据空字段猜模式。
HTTP 使用同形 discriminated union:
```json
{
"point": {
"type": "throughTurn",
"turnId": "canonical-turn-id"
}
}
```
### 3.2 两类能力
Session lifecycle capability:
```ts
interface AgentActivitySessionLifecycleCapabilities {
fork: boolean;
forkThroughTurn: boolean;
}
```
能力由确切 runtime Session + Driver attestation 得出,不按 provider 名称硬编码。Host
执行时再次解析 capability,因此 GUI 中缓存的 true 不能绕过服务端校验。
当前规则:
- Codex app-server 版本满足门槛且协议支持 `lastTurnId`:
`forkThroughTurn=true`;
- Tutti Agent app-server 版本不低于 `0.0.10` 且协议支持 `lastTurnId`:
`forkThroughTurn=true`;
- Claude Agent SDK 能读取 exact transcript、且 canonical provider Turn UUID
与其 root user-message prefix 匹配:
`forkThroughTurn=true`;
- 其他 Agent、不满足门槛的 Codex/Tutti Agent runtime、以及没有真实 SDK UUID 的旧 Claude
Turn:`forkThroughTurn=false`;
- 当前所有接入:`fork=false`,完整会话按钮不展示。
provider Turn 绑定不能仅凭 ID 非空成立。Turn 只保存通用
`RootProviderTurnID` 和 opaque `provider_turn_binding_json`;JSON 的 schema、写入和
forkability 判断均由具体 Agent 的 `ProviderTurnBindingAdapter` 维护。服务端展示按钮和
实际 Fork 都调用同一个 `CanForkProviderTurn` hook,Host/Store 不解析 Claude checkpoint
或其他 Agent 私有字段。迁移只把确有旧 checkpoint 证据的行转换为 Agent JSON;仅有
synthetic/provider-looking ID 的历史行仍为 `{}`,投影为 `recovery_required`,GUI
不展示 Fork。转换完成后旧 Claude 专属列会被删除。
结构 capability 与瞬时 availability 分开:
- capability=false:隐藏入口;
- capability=true 但 Session 忙、存在 pending Interaction 或 boundary 不可证明:
capability 仍为 true,但按钮禁用或 Host fail closed;
- Host 永远做最终校验。
## 4. throughTurn 语义
`throughTurn(turnId)` 是包含边界:
```text
Turn 1 -> Turn 2 -> Turn 3
^
throughTurn
child = Turn 1 + Turn 2 + Turn 3
```
它不同于 `beforeTurn`:后者排除所指 Turn。当前产品和协议只使用 throughTurn,不使用
实验性/deprecated rollback 来模拟。
source 必须满足:
- 是 canonical `kind=root` Session;
- provider Session identity 非空;
- 没有 active Turn;
- 没有 pending Interaction;
- 没有未结束 Goal control、runtime operation、submit claim 或另一个 Fork;
- 选中 Turn 属于 source、phase=settled;
- Turn sequence provenance 可验证;
- boundary 中不存在 provider-native descendant lane;
- boundary 中不存在尚未定义 immutable manifest 的 session-local attachment reference。
最后两项当前 fail closed,避免 provider history 与 canonical history 不一致。
## 5. Child Session
Fork 结果是独立 root Session:
- 新 `agentSessionId`;
- provider 返回的新 `providerSessionId`;
- `kind=root`;
- `rootAgentSessionId`、`parentAgentSessionId`、`parentTurnId`、
`parentToolCallId` 均为空;
- `forkedFrom` 保存直接 source lineage;
- 可以继续对 child 执行下一次 Fork。
child 继承 prepare 时冻结的:
- agent target、provider、model、settings;
- cwd 和 runtime context;
- rail placement、visibility;
- 从 source title 派生并冻结的 child title,格式为
`<source title> (<同一 source 的有效 Fork 序号>)`,首个 child 例如 `123 (2)`;
- boundary 内的 Turns、Messages、Interactions;非 terminal boundary 在 child
中规范化为 `settled/interrupted`,pending Interaction 规范化为 `superseded`;
- provider 可恢复上下文。
历史 Session 先经过一次 `RuntimePreparation`,同一份 prepared runtime identity 同时用于
driver attestation、provider dispatch 和 canonical child 的 cwd/settings/runtime context。
runtime context 中引用 provider Session identity 的恢复游标不能越过 Fork 边界直接复用。
Claude adapter 只接受 `resumeCursor.resume == canonical providerSessionId` 的游标;child
继承到 source 游标时丢弃该游标,并以 `forkSession` 返回的 child identity 恢复,防止首次
续聊重新进入 source namespace。
env 与 provider target reference 只用于本次 provider dispatch,不作为凭据或临时运行事实写入
canonical snapshot;后续恢复 child 时仍通过正常 `RuntimePreparation` 从 agent target 和已冻结的
canonical settings/context 重新解析。由于进程重启会把未 dispatch 的 `prepared` operation 标记为
failed,所以 Host 不会用缺失的临时 runtime 事实跨进程重发 provider RPC。
child 不继承:
- active Turn、pending Interaction;
- Goal state/control;
- runtime operation、submit claim;
- Tutti mode activation;
- prompt queue、unread、pin;
- usage observation;
- provider-native child Sessions。
历史克隆不是原 ID 的浅复制。Store 根据 operation/workspace/source/target/entity/source ID
确定性生成新的 canonical Turn、Message 和 Interaction ID,并统一替换:
- Turn/Message/Interaction owner Session;
- Message/Interaction 的 Turn reference;
- `FinalAssistantMessageID`;
- closed allowlist 中的 typed payload references。
普通文本、Markdown、tool output 和 provider/source lineage 不做全局字符串替换。未知
typed reference、重复 key 或非单射 mapping fail closed。同 operation replay 得到相同 ID。
## 6. Durable operation
### 6.1 内部状态
Store 当前状态机:
```text
prepared
-> dispatching
-> provider_accepted
-> committed
prepared|dispatching -> failed
dispatching -> unknown
```
关键约束:
- `(workspace_id, request_id)` 幂等;
- target Session ID 在 prepare 时独立 reservation;
- 相同 request hash replay 返回原 operation;
- 相同 request ID、不同 hash 返回 conflict;
- provider 已可能接受时绝不自动再次 dispatch;
- provider child 状态必须通过 `host_copy` 或 `provider_owned` 的 typed binding
evidence 证明独立可恢复,随后才能写入
`provider_accepted` 并提交 canonical child;
- `provider_accepted` 之后只重试本地 canonical commit。
- source + point 的 durable boundary barrier 覆盖 active、unknown 和
`committed + client-unobserved`;新 identity 命中 barrier 时返回原 operation;
- `committed` 只有经过显式 client-observed ACK 才释放 barrier;ACK 后同一 Turn
仍可创建新的显式分支;
- `unknown` 不接受 ACK,也不释放 barrier。
operation 持久化:
- operation/request/hash;
- source/target canonical identity;
- source/target provider identity;
- ordered target provider Turn IDs、binding mode 和 verification receipt;
- `point_kind`、source canonical/provider Turn;
- commit 后的 target canonical boundary Turn;
- Driver kind/version;
- immutable snapshot/hash;
- status/error 和 dispatch/accept/complete 时间。
lineage 独立存储:
```text
source_agent_session_id
source_turn_id
target_agent_session_id
target_turn_id
operation_id
forked_at_unix_ms
```
Fork clone 会重映射 canonical Turn ID,因此 `source_turn_id` 不能用于 child 时间线定位。
commit 从已验证的 identity map 取得 `target_turn_id`,在同一事务写入 operation 和
lineage。operation 是 hard purge 后 immutable replay 的 authority,lineage 是存活 child
的快速 Session projection。V4 migration 用固定的 canonical-ID v1 规则精确回填历史
committed operation 和 lineage。删除 source 不会把 child 解释成 provider-native child。
### 6.2 Source fence
prepare transaction 在验证 snapshot 的同时建立 active Fork operation。所有会改变
source Fork 语义的 Store 写路径都检查该 operation:
- Turn/Message/Interaction mutation;
- Session settings;
- Goal control;
- runtime operation;
- submit claim;
- delete/clear;
- 另一个 Fork。
source title 和 pin 不影响已经冻结的 Fork snapshot,允许继续更新。operation 进入 failed、
unknown 或 committed 后 source fence 释放。
Host 的 session mutation actor 提供进程内串行化;Store fence 才是 crash-safe
正确性边界。
### 6.3 Runtime preparation
live Session 可以复用已附着 runtime。historical/non-live Session 必须走与 resume 相同的
`RuntimePreparation`:
- cwd;
- env;
- provider target ref;
- runtime context;
- settings。
这些事实只解析一次并冻结;第一次 capability resolve、dispatch 前 re-attestation 和
实际 Fork 使用同一个 prepared identity,避免三次解析得到不同 runtime。
historical Codex capability cache 只能复用 exact launch fingerprint:prepared command、
cwd、env 以及已解析 executable 的 size/mtime/verified identity 都必须一致。无法证明
executable identity 的 shell/package-manager wrapper 不缓存。CLI 升降级或不同 Session
runtime identity 会重新 initialize probe,避免跨 Session 误展示按钮。
## 7. Provider Driver、Codex、Tutti Agent 与 Claude SDK 接入
provider-neutral输入:
```text
source provider Session id
source canonical Turn id
ordered source provider Turn ID prefix
frozen target title
driver kind/version
frozen runtime preparation
```
Codex Driver:
1. 只在 app-server 版本达到已验证门槛时声明支持;
2. 调用 typed `thread/fork`;
3. 将 boundary 对应的 provider Turn ID 传为 `lastTurnId`;
4. 校验响应 thread ID 非空且不同于 source;
5. 校验 `forkedFromId` 与 source 匹配;
6. 校验返回的全部 provider Turn IDs 与 Store 冻结的 prefix 长度、顺序和值完全一致;
7. 任一缺失、重复、截断或额外 Turn 都视为未验证,不提交 canonical child。
8. Host 在 accepted checkpoint 前调用 provider-state binder;Codex binder 从 source
`CODEX_HOME` 精确查找 child rollout,完整校验每条 JSONL、`session_meta.id`、文件
size/SHA-256,只将该 rollout crash-durable 地原子复制到 target `CODEX_HOME`,不复制
整个 provider home,也不建立依赖 source 生命周期的软链接。
Tutti Agent Driver 复用同一 app-server typed `thread/read` / `thread/fork(lastTurnId)`
协议和 Turn binding JSON,但不冒充 Codex 版本:能力由 `tutti_agent/<version>` 独立证明,
当前门槛为 `0.0.10`。provider-state binder 从 source `TUTTI_AGENT_HOME` 精确复制已验证的
child rollout 到 target `TUTTI_AGENT_HOME`,不会写入 `CODEX_HOME`。
provider-state binding 也是 provider 接入能力的一部分。每个 Agent 必须显式选择
`host_copy` 或 `provider_owned` 并提交相应 evidence;`host_copy` binder 缺失或不支持当前
provider 时必须在 provider dispatch 前 fail closed,provider 调用后的 binder 失败进入
`unknown`;缺少 mapping 或 receipt 时不能进入 `provider_accepted`。
这比只验证最后一个 Turn 更严格:最后一个 ID 相同不能证明中间历史没有丢失或重排。
provider-state binding 幂等,但不能只凭同 ID rollout 信任 target:source/target fingerprint
完全相等时直接成功;完整验证后的 target 若以 source 全部字节为精确前缀,则视为 Fork 后
合法追加并保留;target 若仅为 source 的精确前缀,则视为截断并从 source 修复;其他合法但
非前缀的分叉 fail closed。source rollout 已被清理时,只接受完整验证通过的 target。若
provider child 已经创建但 binding 失败,operation 进入 `unknown`,不提交 canonical child,
也绝不重发 provider Fork。
Claude SDK Driver 使用固定版本的官方公开 API:
1. Go adapter 在提交前生成的 UUID 仅用于出站 prompt 关联,不作为 provider 身份;
Claude Code 可能在持久化 transcript 时改写 caller UUID,因此 sidecar 必须等待 root
user-message 回显,读取 Claude 实际 UUID 后发出 `provider_turn_started`,canonical
`RootProviderTurnID` 只接受该已观察身份;
2. sidecar 为主链 user/assistant/system 消息发出
`provider_turn_checkpoint`;Claude adapter 的写入 hook 将最新外层 UUID 写进自己的
binding JSON(当前为 `checkpointMessageId`),Store 只做 opaque JSON 持久化;Turn
settlement 不冻结 JSON,因为尾随 system message 可能稍后到达;
3. capability 只声明固定 SDK/sidecar 的结构能力,不读取 transcript。Prepare 将所选
Turn 的 `RootProviderTurnID` 与 `provider_turn_binding_json` 一起冻结到 operation;
新 Turn 在 dispatch 时 O(1) 由 Claude adapter 解出 checkpoint。迁移前的旧 Turn JSON
为空时,才读取一次
source transcript,查找所选 origin root user UUID,并取下一个 origin root user
之前的最后消息 UUID;task notification 与内部 synthetic user 不作为新 Turn 边界;
4. 调用 `forkSession(source, {upToMessageId, title: frozenTargetTitle})`;
5. Fork 后只读取 child 的 `getSessionInfo` / `getSessionMessages`,确认 child 身份独立可读;
不复读 source,也不比较 Tutti 与 provider 的历史 prefix;
6. child UUID 会被 SDK 重映射。sidecar 只在 child 尾部定位最后一个真实 origin root user
作为所选 Turn 的新 provider 身份,并从该 Turn 起取最后一个可见消息 UUID 作为 child
checkpoint;它不验证更早历史消息的 UUID 完整性,最终返回只绑定 source/child
session、所选 source/child Turn 与两端 checkpoint 的 `provider_owned` receipt;
7. Store 在 `provider_accepted` checkpoint 原子保存 mapping/receipt,并在 canonical clone
时重写所选 child Turn 的 `RootProviderTurnID` 与 Claude adapter 返回的 binding JSON,
使 fork child 可以继续 Fork;
8. child 恢复时拒绝 source 的 stale `resumeCursor`,只从 canonical child
`providerSessionId` 启动;该约束覆盖进程重启后的首次续聊。
`forkSession` 调用前失败是 `not_started`;一旦调用开始,transport、SDK 或 post-verify
失败都是 `unknown`。Claude provider child UUID 由 SDK 生成,driver 不声明 deterministic
target identity;Host 仍预留 deterministic canonical target Session ID,但绝不重放
`unknown` provider mutation。这样不会因响应丢失或复验失败而创建第二个 provider child。
损坏 target 的修复规则更严格:只有首条 `session_meta` 已精确验证 child identity,且包括
partial tail 在内的全部原始字节都是 source rollout 的严格前缀时,才允许从 source 原位
重建。其他损坏状态一律 fail closed 且不覆盖 target;特别是
`source baseline + 完整 post-Fork history + partial tail` 不得回滚为 source baseline。
### 7.1 Delivery disposition
runtime 到 Host 使用显式 delivery disposition:
| disposition | 含义 | Host 结果 |
| ------------- | ------------------------------------------- | ----------------- |
| `not_started` | RPC 前失败,确定未发送 | failed |
| `rejected` | 收到明确 JSON-RPC error response | failed |
| `unknown` | timeout/disconnect/响应不可验证 | unknown |
| `accepted` | provider child 已创建且所选 Turn 已重新绑定 | provider_accepted |
明确 provider rejection 不归类为 unknown;反之,RPC 已发送后的 timeout 也不能归类为
普通失败并生成第二个 provider child。`accepted` 是进入 `provider_accepted` 的硬门槛;
即使 adapter 返回了非空 child ID,`not_started/rejected/unknown/空值` 也不能提交
canonical child。
## 8. Public API
### 8.1 创建/重放
```http
POST /v1/workspaces/{workspaceID}/agent-sessions/{agentSessionID}/fork
```
请求:
```json
{
"requestId": "caller-stable-id",
"targetAgentSessionId": "caller-stable-target-id",
"point": {
"type": "throughTurn",
"turnId": "canonical-turn-id"
}
}
```
一旦 operation 已创建,provider/local 结果通过 typed operation 返回,不再丢成普通 HTTP
错误:
- `accepted`:HTTP 202;
- `committed`、`failed`、`unknown`:HTTP 200;
- operation 创建前的 invalid argument、not found、conflict 等仍为 4xx。
### 8.2 查询
```http
GET /v1/workspaces/{workspaceID}/agent-session-fork-operations/{operationID}
```
公开 operation:
```ts
interface WorkspaceAgentSessionForkOperation {
operationId: string;
requestId: string;
sourceAgentSessionId: string;
targetAgentSessionId: string;
point: { type: "throughTurn"; turnId: string };
status: "accepted" | "committed" | "failed" | "unknown";
session: WorkspaceAgentSession | null;
lineage: WorkspaceAgentSessionForkLineage | null;
error: string | null;
}
```
`committed` 必须携带完整 Session 和 lineage。该结果从 operation 的 immutable snapshot
重建,不依赖 child 当前仍存在,因此 commit 后删除 child 也不会让 operation GET 退化为
502。Activity desktop adapter 对 accepted operation 使用 GET 调和,直到得到 terminal
operation;若调和 transport 失败,则以 delivery-unknown 交回 Engine。
GET 是纯查询,绝不隐式确认 operation。
### 8.3 Client-observed ACK
```http
POST /v1/workspaces/{workspaceID}/agent-session-fork-operations/{operationID}/acknowledge
```
ACK 仅接受 `committed` operation,并且幂等返回同一 immutable operation。Engine
只有在校验 committed result、把 authoritative child Session 纳入 canonical state
以后才发送 ACK。ACK 失败只保留 `ackPending` 并重试 ACK,不能回滚 child、改写成功状态
或再次调用 provider。服务端在 ACK 事务中更新 `client_observed_at` 并释放 source + point
barrier;ACK 与新 Fork 并发时由 Store 事务决定顺序。
ACK effect 使用 10 秒 deadline;失败或超时后 Engine 通过自身 expiry clock 按
`1s / 2s / 5s / 10s / 30s` capped backoff 自动重发相同 operation ID 的幂等 ACK,
不依赖新的 Session event 或用户点击。成功和 Engine dispose 会停止或取消后续 retry;
child/source 的后续删除不会撤销已经成立的 observation,未完成的 ACK 仍继续幂等重试。
unresolved ACK record 不参与普通 mutation 裁剪,也会阻止同 boundary 生成新的 provider
Fork intent,但不会阻止 pin/delete 等无关 Session mutation。
因此,服务端已经 commit 但 POST 响应丢失时,即使 App 重启并生成了新 request/target,
新请求也只会拿回原 committed operation 和原 target。收到并纳入该 child 后 ACK;用户
下一次显式点击才会创建第二个分支。
### 8.4 Session lineage
所有 Session projection 都有 required + nullable:
```ts
interface WorkspaceAgentSessionForkLineage {
sourceAgentSessionId: string;
sourceTurnId: string;
targetTurnId: string;
operationId: string;
forkedAtUnixMs: number;
}
```
普通 root 和 provider-native child 为 null;Fork root 非 null。该字段贯通 list/detail、
service/API mapper、generated client、Activity normalization 和 GUI。
## 9. AgentSessionEngine 与 GUI
Engine 的 mutation record 是唯一前端状态源,保存:
- request ID;
- target Session ID;
- operation ID;
- ACK pending/observed 状态;
- source Session/Turn;
- `inFlight | succeeded | failed | unknown`。
行为:
- 新 boundary 首次提交时 Engine 生成 request/target identity;
- inFlight 重复点击不发第二条命令;
- confirmed failed 后的显式新尝试分配新 request/target identity;
- unknown 的重试必须复用原 request/target identity;
- unresolved unknown 不参与普通 128 条 settled mutation 裁剪;若 Engine 重启已丢失前端
identity,Host 会按 source+point 找回 durable unknown operation 并禁止第二次 provider
dispatch;
- committed 但未 ACK 的 operation 同样由 source+point barrier 找回;recovered result
必须采用 durable operation 原 request/target/session identity,不能伪装成新 target;
- accepted operation 保持 inFlight,直到 GET 返回 terminal 或 canonical target Session
upsert;
- committed operation 原子 upsert authoritative Session,然后 mutation succeeded 并发出
client-observed ACK;ACK 失败只进入 ackPending;
- React 只传 workspace/source/turn,不保存 request ref 或私有 pending map。
GUI:
- 只有 `forkThroughTurn=true` 的 exact Session 显示 Turn Fork 按钮;
- 只在 canonical settled root Turn 上提供动作;
- pending Turn 从 Engine selector 派生;
- 成功后打开 authoritative target Session;
- Fork child 不在顶部展示 source Session;时间线在 `targetTurnId` 对应 Turn 完整内容后
展示“接续自任务”分隔线;
- 分隔线复用 Turn attachment 的虚拟化定位;边界 Turn 的分页尚未加载时保持隐藏,不能
降级到时间线末尾。
## 10. Recovery
Host 启动恢复按稳定分页扫描:
- `prepared`:没有 dispatch marker,进程重启时标记为 `failed` 并释放 fence/reservation;调用方需要显式发起一次新尝试;
- `dispatching`:不盲目重发 provider RPC;按 delivery evidence 进入 unknown;
- `provider_accepted`:provider-state binding 已完成,只执行幂等 canonical commit;
- terminal operation 不进入恢复队列。
`committed + client-unobserved` 虽然不需要 provider/canonical 恢复,仍保留 boundary
barrier,直到 Engine 显式 ACK。`failed` 立即释放;`unknown` 永久保留,等待未来独立的
人工风险处置协议。
Host 在 provider dispatch 和关键 Store checkpoint 使用独立短 checkpoint context,避免
HTTP caller cancellation 让已创建 operation 丢失状态。POST 超过同步 checkpoint 可以返回
accepted/202;operation GET 在 source actor 内幂等推进 `provider_accepted -> committed`,
启动恢复负责进程重启后的同一转换,客户端也可通过 canonical Session event 收敛。GET 遇到
遗留 `prepared` operation 会将其标为 failed;遇到已经写入 dispatch marker、但没有 durable
provider result 的 `dispatching` operation 会将其标为 unknown。两种情况都不会重新解析 runtime
或触发 provider RPC。
对早期已提交但尚未完成 namespace binding 的 Codex Fork,resume preparation 会根据
canonical lineage 与 committed operation 重新验证 source/target identity,并执行同一个
幂等单-rollout binding;因此升级后的首次继续对话可以自修复,且不会重新调用
`thread/fork`。后续每次 resume 重复执行 binder 时,target 对 source 的合法追加会被保留,
target 仅为 source 的完整记录前缀时会从 source 修复,其他非前缀分叉 fail closed,避免把
子会话已经追加的新历史覆盖回 Fork 初始状态。若 target 尾部已损坏,则仅在 target 的全部
原始字节仍是 source 的严格前缀时重建;包含完整 post-Fork 历史的损坏 target 保持原样并
fail closed。
## 11. 当前限制与后续
当前明确不支持:
- 完整会话 Fork 产品入口;
- 除 Codex、Tutti Agent 与 Claude Code 外的其他 Agent Driver;
- 没有原生历史边界能力时的消息 replay 模拟;
- 从 Message 中间位置 Fork;
- 包含 session-local attachment 的 boundary;
- 包含 provider-native descendant lane 的 boundary;
- Git branch/worktree/filesystem snapshot;
- 跨 provider Fork;
- Fork tree/ordinal UI;
- provider accepted 后的人工 repair/abandon 工作流。
下一步接入完整会话 Fork 时:
1. 在 Host 为 `Point=latestSettled` 增加明确分支和 Store snapshot 规则;
2. Driver 单独声明 `FullSession=true`;
3. API 扩展 Point union;
4. Engine 复用现有 operation/mutation,不新建 saga;
5. GUI 只在 `fork=true` 时展示 Session 级入口。
不同 Agent 独立接入:
- 各 Driver 报告真实 `FullSession/ThroughTurn`;
- 能证明 provider/canonical history 一致才置 true;
- 不支持的 Agent 保持 false,GUI 不展示;
- 禁止用品牌名推断或用历史消息 replay 伪造原生支持。
## 12. 验证要求
发布前至少覆盖:
- Store snapshot、source fence、deterministic ID remap、replay、二次 Fork和 race test;
- Host live/historical RuntimePreparation、re-attestation、recovery 和 disposition;
- Codex exact ordered provider prefix、版本门槛、RPC rejection/timeout;
- Tutti Agent 独立版本门槛、binding hook、`TUTTI_AGENT_HOME` state binding;
- Service/API 200/202/4xx 和 POST/GET typed operation;
- generated API 幂等检查;
- Activity Engine accepted/failed/unknown/committed 和 identity reuse;
- GUI capability visibility、pending、lineage navigation;
- desktop adapter accepted polling;
- Agent Host、provider、renderer 和 AgentGUI 架构边界检查。
@@ -0,0 +1,457 @@
# Agent Side Conversation Technical Design
Status: implemented.
## 1. Objective
AgentGUI Side opens a short-lived conversation from the provider's current
context without stopping or mutating the parent conversation. The source may
have an active Turn. Side output is visible only through the live runtime
channel and never becomes a canonical Session, Turn, Message, lineage row, or
recovery operation.
This is intentionally separate from durable Session Fork:
| Dimension | Durable Session Fork | Live Side |
| ----------------- | -------------------------------------- | -------------------------------- |
| Source boundary | full session or settled Turn | provider's current live snapshot |
| Child identity | canonical Session | runtime-only side id |
| Provider state | durable/resumable | ephemeral/non-resumable |
| Local persistence | history, lineage, operation saga | none |
| Restart behavior | recover and resume | expire |
| Event delivery | optimistic plus post-commit projection | transient only |
## 2. Architecture
```mermaid
flowchart LR
UI[AgentGUI Side Pane]
API[Side Command API]
Host[Agent Host Side Lifecycle]
Runtime[Runtime Controller]
Registry[Side Scope Registry]
Buffer[Open Event Buffer]
Contract[Side Adapter Contract]
Codex[Codex Adapter: implemented]
Claude[Claude Adapter: future]
Other[Other Agent Adapter: future]
Provider[Provider Runtime / Transport]
Transient[Transient Event Observer / EventHub]
Durable[Durable Activity Reporter]
UI --> API --> Host --> Runtime
Runtime --> Registry
Runtime --> Buffer
Runtime --> Contract
Contract --> Codex
Contract --> Claude
Contract --> Other
Codex --> Provider
Claude --> Provider
Other --> Provider
Provider --> Codex --> Buffer
Provider --> Claude --> Buffer
Provider --> Other --> Buffer
Buffer --> Transient --> UI
Runtime -. shared Exec / Cancel / Respond / Close .-> Contract
Runtime -- canonical scope only --> Durable
Registry -. side scope is rejected .-> Durable
```
Responsibilities:
| Layer | Responsibility |
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
| AgentGUI | create side id/request id, render a separate pane, subscribe to transient events, close on pane disposal |
| Service API | authorize workspace/source access and translate commands to Agent Host |
| Agent Host | product lifecycle, open idempotency, active-source policy, send/cancel/respond/close entry points |
| Runtime Controller | scope registry, open transaction, event buffering, durable isolation, no-resume rule, shared lifecycle reuse |
| Provider Adapter | attest exact runtime capability and create the native ephemeral branch |
| Provider runtime | snapshot/fork current model context and execute side Turns |
## 3. Provider Integration Contract
Every provider continues to implement the ordinary `Adapter` contract:
```go
type Adapter interface {
Start(...)
Resume(...)
Exec(...)
Cancel(...)
Close(...)
}
```
A Side-capable provider additionally implements:
```go
type SideConversationAdapter interface {
SideCapabilities(context.Context, Session) (SideConversationCapabilities, error)
OpenSide(context.Context, SideConversationAdapterOpenInput) (SideConversationOpenResult, error)
}
```
`SideCapabilities` is resolved by the exact selected adapter, not inferred
from the provider name. The adapter may use persisted runtime metadata when
the provider supports loading a historical source identity. `OpenSide` is the
provider-specific creation operation; interactive Side flows require the
existing `InteractiveAdapter`. Standard event, command, and config sinks
remain part of the provider integration. After open, the Controller reuses
ordinary Exec, Cancel, interactive response, state, and Close behavior against
the side-scoped Session.
`OpenSide` owns failure cleanup: before returning an error it must quiesce its
callbacks and release any provider child it created. A successful return
transfers ownership to `Close`.
Required capability facts:
- `Supported`: the exact runtime supports native Side.
- `ActiveSourceTurn`: opening while the source Turn is running is safe.
- `Ephemeral`: the provider child is not durable.
- `HideInheritedTurns`: inherited turns may be model-visible but are not
rendered as Side-local history.
- `ModelBoundaryInjected`: the provider has an explicit model-visible boundary
between inherited context and Side-local input.
Providers that cannot prove these facts fail closed.
## 4. Runtime Session Scope
`RuntimeSessionScope` has two values:
- `canonical` (including the legacy empty value);
- `side`.
A Side Session carries:
- `AgentSessionID`: host-generated side identity;
- `SourceAgentSessionID`: canonical parent identity;
- `SideRequestID`: caller-stable open idempotency key;
- `ProviderSessionID`: native ephemeral child identity;
- `Resumable=false`;
- `Visible=false`.
Side sessions are addressable by direct runtime identity, but are excluded from
canonical session listings.
## 5. Open Transaction
Opening Side is a two-level transaction.
### 5.1 Agent Host
1. Validate workspace, source id, side id, and request id.
2. Reject a side id already present in the canonical store.
3. Serialize source and side mutations through their session actors.
4. Reserve `(workspaceId, sideId)` as `creating`.
5. Resolve the live source runtime and exact adapter capabilities.
6. Permit an active parent only when `ActiveSourceTurn=true`.
7. Dispatch `OpenSideConversation`.
8. Validate returned workspace, side identity, scope, parent identity, and all
required capability facts.
9. Commit the registration as `ready`, or remove the reservation on failure.
An identical `(sourceId, sideId, requestId)` retry returns the committed
registration. A conflicting reuse of `sideId` fails.
### 5.2 Runtime Controller
1. Acquire the side identity lifecycle lock.
2. Register a provisional side-scoped Session before provider dispatch.
3. Buffer provider events, command snapshots, and config updates emitted while
`OpenSide` is in flight.
4. Invoke the provider Adapter.
5. Validate the returned provider child.
6. Commit the final Session while retaining the provisional gate.
7. Publish `session.started`, repeatedly drain buffered transient events, and
only then atomically switch the gate to live.
8. On failure, discard the Session and every buffered side channel.
This ordering prevents fork notifications from being routed to the parent,
dropped, or exposed for a Side that ultimately failed to open.
## 6. Execution and Event Routing
Side reuses the Controller's existing execution machinery:
- prompt validation and normalization;
- Turn admission and local active-Turn tracking;
- Adapter Exec/ExecAsync;
- exact cancellation;
- interactive response;
- command/config snapshots;
- provider Close.
`InteractiveAdapter` implementations share one error taxonomy:
`ErrInteractiveResponseInvalid` for malformed or unavailable options,
`ErrInteractiveRequestNotLive` for stale requests, and
`ErrInteractiveAlreadyAnswered` after terminal resolution. This keeps Side API
classification provider-neutral when additional adapters opt in.
The scope changes the sinks:
```text
canonical -> RuntimeStreamObserver + EventHub + DurableActivityReporter
side -> SideStreamObserver + EventHub
```
Every durable-report entry point checks the Session scope. Side submit,
streaming, terminal, snapshot, goal reconcile, and goal provenance paths are
rejected or become no-ops before the durable reporter. Goal control and
reconcile APIs are unsupported for Side until a provider-neutral transient
goal contract exists. Side events remain available synchronously through the
dedicated observer and per-session EventHub.
Durable Session Fork may recover a missing selected-Turn provider binding from
authoritative provider history before dispatch. Side must never call that
recovery path: it forks the exact live connection without `lastTurnId`.
Likewise, a Side `clientSubmitId` is transient correlation and carries no
canonical submit occurrence.
The public business-event bridge forwards a discriminated Side event union for
message deltas, message updates, state patches, command/config snapshots, and
session audits. Every variant is validated by the generated event protocol
before it is exposed over the public WebSocket.
## 7. Expiration and Cleanup
Side is never resumed:
1. If the Adapter still owns the live child, operations proceed normally.
2. If the child connection is absent, `ensureLiveAdapterSession` returns
`ErrSideConversationExpired`.
3. It must not call Adapter Resume.
4. Explicit Close interrupts any active side Turn, releases the provider child
without closing a shared parent connection, removes runtime state, and
clears the Host registration.
5. Close is idempotent at the Host boundary.
Daemon restart implicitly expires every Side because neither Host nor runtime
registrations are durable.
## 8. Codex Reference Implementation
Codex forks on the source-owned app-server connection while it is live. This is
required for a source Turn whose newest state exists only in that process's
memory. For an idle or historical source, Codex starts a Side-owned app-server
connection and loads the persisted source by `threadId` without resuming the
canonical source session. Host supplies the persisted runtime snapshot through
the host-adapter boundary, including the isolated `CODEX_HOME` needed to locate
the source rollout. The historical capability probe fails closed if this
location metadata is absent. Process launch and provider preparation use the
Side identity; only the `thread/fork` request carries the canonical source
thread id.
```mermaid
sequenceDiagram
participant H as Host/Controller
participant C as Live-source or Side-owned app-server client
participant P as Codex app-server
C->>P: thread/fork(threadId=parent, ephemeral=true, excludeTurns=true)
Note over C,P: lastTurnId is omitted; active parent is allowed
P-->>C: child thread + forkedFromId
C->>P: thread/inject_items(child, side boundary)
P-->>C: accepted
C-->>H: provider child identity
H->>C: ordinary turn/start for Side prompt
```
Codex validation rules:
- exact initialized runtime version must support the required protocol;
- a live client must own the exact parent thread, or the historical snapshot
must carry the exact provider thread and isolated Codex home;
- child id must be non-empty and different from the parent;
- `forkedFromId` must equal the parent thread id;
- boundary injection must succeed before open commits;
- a live parent and child remain on one connection and are routed by
`threadId`; a historical child uses a dedicated connection;
- child notifications that arrive before `thread/fork` responds are buffered
by a provisional connection route and replayed only after child identity
validation;
- per-RPC handlers cannot override the connection-wide thread router;
- a shared client is reference-counted across its parent and Side sessions;
- closing Side unsubscribes the ephemeral child and either drops only the
shared Side reference or closes its dedicated historical connection; the
parent identity and active Turn are unchanged.
- a failed physical close retains a Side-owned cleanup handle for bounded
retry instead of orphaning the dedicated process.
The injected developer instructions and user boundary tell the model that
inherited turns are read-only context and subsequent messages belong only to
the Side conversation.
## 9. Error Contract
| Error | Meaning | Retry behavior |
| -------------------------------- | ------------------------------------------ | ---------------------------------------------- |
| `ErrSideConversationUnsupported` | adapter/runtime cannot attest native Side | do not retry until capability changes |
| `ErrSideConversationInProgress` | same Side identity is currently opening | wait or retry with the same request id |
| `ErrSideConversationConflict` | side id was reused with different identity | allocate a new side id |
| `ErrSideConversationExpired` | ephemeral child is no longer live | open a new Side |
| provider error | native fork/boundary operation failed | retry only with the same side/request identity |
## 10. Conformance Requirements
The provider-neutral conformance suite verifies:
- Side opens while the parent Turn is active;
- the parent remains active;
- Side is marked ephemeral and non-resumable;
- Side send produces transient events;
- no Side operation produces canonical writes;
- Close releases the Side while leaving the parent active.
Runtime tests additionally verify:
- canonical and Side observers are isolated;
- failed open discards buffered events;
- expired Side never calls Resume;
- Side is absent from canonical runtime listings;
- Codex sends the exact fork and boundary RPCs;
- Codex active-parent Side uses exactly the source process/connection;
- closing Codex Side deletes only its child and retains the shared process;
- Host Open versus Close cannot leave a ghost ready registration;
- mandatory capability facts fail closed;
- goal reconcile/provenance and submit provenance produce zero Side writes.
## 11. Delivery Phases
### Phase A — infrastructure and Codex reference adapter
Implemented:
- scope and provider contract;
- Host lifecycle;
- runtime open transaction;
- transient observer;
- durable/no-resume isolation;
- Codex ephemeral fork and boundary injection;
- conformance and focused tests.
### Phase B — service contract
Implemented:
- OpenAPI-generated resolve/open/send/cancel/respond/close endpoints;
- workspace/source identity validation delegated through the single
application Host lifecycle;
- conflict, unsupported, expired, invalid, and downstream error mapping;
- workspace-scoped `agent.side.updated` transient events with source/Side
identity and a monotonic Side-local sequence;
- strict separation from `agent.activity.updated`.
Implemented command surface:
| Command | Required identity | Result |
| -------------------- | --------------------------------------------- | -------------------------------- |
| `agent.side.resolve` | workspace + source session | exact capability facts |
| `agent.side.open` | workspace + source + side + request id | ephemeral Side descriptor |
| `agent.side.send` | workspace + side + turn + client submit id | accepted Turn descriptor |
| `agent.side.cancel` | workspace + side + exact turn | cancellation result |
| `agent.side.respond` | workspace + side + turn + interaction request | interaction result |
| `agent.side.close` | workspace + side | idempotent closed/expired result |
The public transient envelope carries `workspaceId`, `sideAgentSessionId`,
`sourceAgentSessionId`, a monotonic Side-local `sequence`, a discriminated
`eventType`, and typed variant `data`. It is never replayed from canonical
history. Disconnect or a sequence gap returns `expired` and asks the client to
open a new Side instead of synthesizing a durable replay.
### Phase C — AgentGUI
Implemented:
- capability-gated `/side`, including while the source Turn is active;
- optional provider-neutral Side runtime and desktop transport adapter;
- separate Side pane/store keyed by Side id;
- inherited-history-hidden rendering;
- sequence-gated transient event subscription;
- exact-turn cancel and interactive-response transport;
- close, source-switch cleanup, and disconnect expiry behavior.
The renderer host exposes a runtime factory. Each mounted AgentGUI surface
creates and disposes its own Side runtime so embedded and detached windows
cannot overwrite one another's transient ownership. The renderer keeps Side
in that dedicated store keyed by workspace and active `sideId`;
canonical Session/Turn/Message reducers must reject `scope=side`. Closing a
pane sends `agent.side.close` and keeps the pane in `closing` until the remote
acknowledgment; a failed close remains visible and retryable. A daemon
disconnect immediately clears every visible local Side identity and retains a
close tombstone until remote cleanup succeeds; the next open retries unresolved
cleanup first.
### Phase D — additional providers
For each provider:
1. implement `SideCapabilities`;
2. implement `OpenSide`;
3. implement exact live-session probing and failure cleanup;
4. implement the ordinary optional interfaces used by its advertised Side UX
(for example interactive response);
5. pass the shared conformance suite;
6. add one protocol-level test proving ephemeral identity, active-source
behavior, boundary semantics, and cleanup.
No provider-specific Side state machine should be added outside the Adapter.
Minimum provider composition:
| Capability | Required when |
| ---------------------------------- | ------------------------------------------------ |
| `SideConversationAdapter` | always |
| ordinary `Exec`, `Cancel`, `Close` | always |
| `InteractiveAdapter` | provider can emit approvals/questions |
| command/config/event sink adapters | provider advertises those live updates |
| connection/thread router | multiple Side roots share one provider transport |
## 12. Operational Observability
Before product rollout, add metrics for:
- open attempts, accepted, unsupported, failed, and expired;
- open latency split by initialize/fork/boundary injection;
- active-parent versus idle-parent opens;
- buffered event count and discarded event count;
- live Side count and close reason;
- any attempted durable report for a Side scope as a high-severity invariant
violation.
Logs and traces must include workspace id, source session id, side id, provider,
provider child id after acceptance, request id, and phase. They must not include
prompt text, injected model context, credentials, or environment values.
## 13. Architecture Review Closure
The implementation review produced the following corrections:
| Review risk | Resolution |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| cross-process fork could miss the active source suffix | Codex now forks on the source-owned connection |
| one callback handler could mix parent and Side events | connection-wide `threadId` router overrides per-RPC handlers |
| fork could emit a child notification before child identity exists | provisional connection route buffers then replays against the committed Side |
| legacy approval requests use `conversationId` | one thread-id extractor handles modern and legacy request schemas |
| closing Side could terminate the parent process | shared-client reference counting plus child `thread/unsubscribe` |
| an unhealthy shared transport could leave sibling refs live | transport invalidation expires every parent/Side ref and closes once |
| Host Open/Close could create a ghost ready entry | source/side session actors serialize the transition |
| a stale Host registration could target a canonical runtime | every Side operation revalidates scope, source, and request |
| a canonical id could be reused as a Side id | Host checks the canonical store before reservation |
| events or command/config snapshots could overtake open | one provisional drain loop covers all three channels |
| partial capability claims could commit | preflight and open results validate every mandatory fact |
| historical Side open could mutate canonical source liveness | the dedicated client loads by `threadId`, routes fallback events as Side, and is owned only by the Side session |
| goal/provenance paths could write Side state durably | goal APIs are disabled and every durable sink has a Side guard |
| the canonical idle reaper could silently expire Side | Side is excluded; explicit Side lifecycle owns expiration |
| provider failure could leave delayed callbacks/resources | `OpenSide` keeps resource-creating response identities until response/client shutdown and cleans late children |
| malformed fork lineage could leak an ephemeral child | cleanup is armed as soon as a distinct child id is returned |
| invalid provider identity could close a canonical session | unvalidated identities are never passed to ordinary `Close` |
| two AgentGUI surfaces could share transient Side ownership | Desktop injects a factory and each mounted surface owns one runtime |
| a stale connection sample could admit Side without events | connection state methods are mandatory and sampled around subscription/open |
| a parent child index could steal the exact Side thread | exact provider-session matches run before child-thread fallback |
This document covers the provider-neutral infrastructure, Codex adapter,
public service command/event schema, and the user-visible AgentGUI `/side`
vertical slice.
@@ -0,0 +1,443 @@
# Tutti Agent `skills/list` 接入方案
Status: implemented; pending cross-platform artifact validation
## 1. 背景与目标
Tutti Agent 与 Codex 使用相同的 app-server 协议。当前 Tutti Agent 已声明
`skills` capability,但没有接入 Composer capability catalog,因此
AgentGUI 收不到 `skills/list` 的结果。
已核实:
- Tutti Agent `0.0.10` 支持
`initialize -> initialized -> skills/list`;本机调用返回 49 个启用的
skills。
- tuttid 现有 Codex capability lister 已经具备 app-server 进程管理、
`skills/list` 请求、响应解析、30 秒缓存和错误降级。
- `skills/list` 结果已能映射成
`ComposerCapabilityOption{Kind:"skill", Invocation:"promptItem"}`。
- OpenAPI capability DTO、Activity Adapter、AgentGUI 和 runtime 已完整支持
上述 capability,无需增加 skill DTO 字段。
- AgentGUI 当前只允许 `$` 查询 skills,符合本次确认的产品要求。
目标是复用 Codex 的现有链路,为 Tutti Agent 增加“只请求
`skills/list`”的 catalog 模式,使 Tutti skills 出现在 `$` 面板并按
`promptItem` 提交。
## 2. 当前架构和核心问题
### 2.1 当前 Composer 链路
```text
AgentGUI
-> AgentGUIRuntime.getComposerOptions
-> POST /v1/agent-providers/{provider}/composer-options
-> tuttid Service.GetComposerOptions
-> listComposerCapabilityOptions
-> ComposerOptions.capabilityCatalog
-> OpenAPI capability DTO
-> activity-tuttid-adapter
-> providerSkillsFromComposerOptions
-> AgentGUI "$" palette
-> structured promptItem
-> app-server turn/start
```
这条链路对 Codex 已经存在,但 Tutti Agent descriptor 没有声明
`CapabilityCatalog`:
```text
Tutti Agent CapabilityCatalog.Kind = ""
-> composerCapabilityCatalogLister 返回 unsupported=false
-> capabilityCatalog=[]
-> AgentGUI availableSkills=[]
```
### 2.2 Codex 现有实现
Codex descriptor 使用 `CapabilityCatalogKindCodexAppServer`。当前 lister
启动 `codex app-server` 后一次请求:
```text
skills/list
app/list
plugin/list
mcpServerStatus/list
```
解析完成后,Codex 的
`NativePluginCatalogAuthoritative=true` 会只向 Composer 投影
sites/browser/computer-use 等 native plugins。
Tutti Agent 没有 app/plugin/MCP,因此不能直接照搬“四个方法全请求”,但
可以复用同一个 lister,仅把请求集缩小为 `skills/list`。
### 2.3 已经可直接复用的后半段
现有 `parseCodexSkillCapabilities` 已生成:
```text
kind=skill
status=available | disabled
trigger=$<name>
path=<SKILL.md>
invocation=promptItem
```
当 provider 没有设置 `NativePluginCatalogAuthoritative` 时,
`providerSkillsFromComposerOptions` 会把 available、具有 path 的
`promptItem` skill capability 转为 AgentGUI skill。
AgentGUI 当前明确只放行 `$`:
```ts
const skillQueryMatch =
triggerQueryMatch?.prefix === "$" ? triggerQueryMatch : null;
```
提交时现有代码会生成:
```json
{
"type": "skill",
"name": "review",
"path": "/path/to/review/SKILL.md"
}
```
因此问题只在 app-server catalog 的“入口和请求范围”,不在 HTTP、前端或
runtime。
## 3. 目标架构和完整链路
```text
Tutti Agent ProviderDescriptor
CapabilityCatalog.Kind = app_server_skills
|
v
composerCapabilityCatalogLister
command 来自 RuntimeDescriptor:
["tutti-agent", "app-server"]
requestSet = skillsOnly
|
v
现有 app-server lister
initialize
initialized
skills/list(cwds=[cwd], forceReload=false)
|
v
现有 parseCodexSkillCapabilities
-> ComposerCapabilityOption(kind=skill, trigger=$name,
path, invocation=promptItem)
|
v
ComposerOptions.capabilityCatalog
-> 现有 OpenAPI capability DTO
-> 现有 Activity Adapter
-> 现有 providerSkillsFromComposerOptions
-> 现有 "$" skill palette
|
v
选择 "$review"
-> 现有 promptItemBlocksForProviderSkills
-> {type:"skill", name:"review", path:"..."}
-> 现有 appServerUserInput
-> tutti-agent turn/start
```
Codex 和 Tutti Agent 的差异只保留在 descriptor:
| Provider | Catalog kind | RPC 请求 | GUI 投影 |
| ----------- | ------------------- | --------------------- | ------------------- |
| Codex | `codex_app_server` | skills/app/plugin/MCP | 现有 native plugins |
| Tutti Agent | `app_server_skills` | 仅 skills | 普通 `$` skills |
## 4. 各仓库、服务和模块改造
### 4.1 `tutti`:provider registry
涉及:
- `packages/agent/daemon/providerregistry/types.go`
- `packages/agent/daemon/providerregistry/registry.go`
- `packages/agent/daemon/providerregistry/providers.go`
新增一个 capability catalog kind:
```go
const CapabilityCatalogKindAppServerSkills CapabilityCatalogKind =
"app_server_skills"
```
registry validation 接受该值。
Tutti Agent descriptor 增加:
```go
CapabilityCatalog: CapabilityCatalogDescriptor{
Kind: CapabilityCatalogKindAppServerSkills,
},
```
不新增 `SkillKind`、`SkillDiscoveryKind`、`TriggerPrefix` 或 provider-specific
GUI behavior。
### 4.2 `tuttid`:现有 app-server capability lister
涉及:
- `services/tuttid/service/agent/codex_capability_catalog.go`
- `services/tuttid/service/agent/codex_capability_catalog_test.go`
给现有 lister 增加一个小型请求范围:
```go
type appServerCatalogRequestSet string
const (
appServerCatalogRequestSetCodex = "codex"
appServerCatalogRequestSetSkillsOnly = "skills_only"
)
```
`composerCapabilityCatalogLister` 根据 descriptor kind 构造:
```text
codex_app_server
-> RequestSet=codex
app_server_skills
-> RequestSet=skills_only
```
把当前固定的 `requests` 和 `pending` 改为由 request set 生成:
```text
codex:
2 -> skills/list
3 -> app/list
4 -> plugin/list
5 -> mcpServerStatus/list
skills_only:
2 -> skills/list
```
握手、进程、scanner 限额、超时、解析函数和错误处理继续使用现有代码。
### 4.3 `tuttid`:Composer capability 投影
仍在 `codex_capability_catalog.go` 内完成,不新增 service 层级。
现有成功分支是:
```go
if kind == codex_app_server {
return codexNativeComposerPluginOptions(result.Options)
}
return mergeComposerCapabilityOptions(fallback, result.Options)
```
新 `app_server_skills` 不进入 Codex native plugin 过滤,因此
`skills/list` 产生的 skill options 会原样进入 `capabilityCatalog`。
失败时沿用现有行为:
- Composer Options 请求仍成功。
- capability catalog 为空。
- 错误写入现有 `capabilityCatalogErrors` 诊断上下文。
- 成功结果继续使用现有 30 秒缓存。
可以补一条非敏感结构化日志,记录
`provider/requestSet/returnedCount/errorCount/durationMs`;不记录 skill
内容、路径或认证信息。
### 4.4 HTTP、Activity、AgentGUI、runtime
这些模块不改生产代码:
| 模块 | 原因 |
| ---------------------------------------- | ------------------------------------------------------- |
| `services/tuttid/api/openapi` | capability DTO 已有 kind/status/trigger/path/invocation |
| `services/tuttid/api` | capability mapper 已传递全部所需字段 |
| `packages/agent/activity-tuttid-adapter` | 已解析 capability invocation |
| `packages/agent/activity-core` | capability 类型已完整 |
| `packages/agent/gui` | 已把 skill capability 投影成 `$` skill;继续只允许 `$` |
| `packages/agent/daemon/runtime` | 已支持 structured skill block |
只增加回归测试,不增加新接口或状态。
### 4.5 `tutti-agent` 仓库
当前 Tutti Agent `0.0.10` 已提供所需 `skills/list` 协议,本次未改该仓库生产代码。
可补一条 contract test,确认以下请求保持兼容:
```json
{
"method": "skills/list",
"params": {
"cwds": ["/workspace"],
"forceReload": false
}
}
```
**待验证:**其他平台发布的 `0.0.10` artifact 是否与本机验证版本一致。
## 5. 文件拆分与 Adapter 边界
本次不需要新建大型 adapter 框架,也不需要新增 package。
建议只在现有文件内增加:
```text
providerregistry/types.go
-> 新 catalog kind
providerregistry/providers.go
-> Tutti descriptor 选择 skills-only catalog
codex_capability_catalog.go
-> request set
-> 动态 requests/pending
对应测试文件
-> Tutti 只请求 skills/list
-> Codex 请求集不变
```
边界保持为:
```text
Provider descriptor
决定使用哪一种 catalog request set
tuttid app-server lister
负责进程、JSON-RPC 和 wire parsing
Composer service
负责缓存、错误降级和 capability projection
AgentGUI
只消费统一 capability contract,不判断 provider
```
## 6. 明确不做
- 不开放 `/` 查询 skills。
- 不修改 AgentGUI skill palette 生产代码。
- 不给 Tutti Agent 请求 app/plugin/MCP。
- 不给 Tutti Agent设置 `NativePluginCatalogAuthoritative`。
- 不新增 `AgentProviderSkillOption.invocation`。
- 不新增 `SkillDiscoveryKind`、`TriggerPrefix` 等 descriptor 字段。
- 不切换 Codex filesystem skills 的来源。
- 不改变 Codex native plugin 展示策略。
- 不引入长驻 app-server 或 `skills/changed` watcher。
- 不新增数据库表、HTTP endpoint、Activity state 或 Host API。
- 不修改 Agent 排序和模型选择器。
## 7. 数据迁移和兼容策略
- 无数据库或用户配置迁移。
- HTTP schema 不变。
- Desktop/daemon 不存在新增字段的滚动升级问题。
- 旧 daemon 没有 Tutti catalog kind,仍返回空 skills,与当前行为一致。
- 新 daemon 使用现有 capability DTO;旧 Desktop 已能解析并展示。
- Claude Code、Cursor、OpenCode 和 Codex 的既有行为不变。
## 8. 风险与回滚
| 风险 | 控制 |
| ---------------------------------------------- | ---------------------------------------------------------------------------------- |
| 修改固定 pending 集合导致 Codex 回归 | 用现有四 RPC fixture 做完整回归 |
| skills-only 仍误发其他 RPC | fake app-server 断言只收到 initialize/initialized/skills/list |
| Tutti app-server 超时 | 沿用现有 8 秒超时、非致命降级和 30 秒缓存 |
| disabled skill 被展示 | 沿用 parser 的 disabled status 和 GUI 的 available filter |
| app-server home/environment 与真实运行环境不同 | 本机 `make dev-gui` 已验证;其他平台仍待验证,只有出现差异证据后再增加环境准备逻辑 |
回滚只需从 Tutti descriptor 移除
`CapabilityCatalogKindAppServerSkills`,即可恢复当前空 catalog;没有数据需要
回滚。
## 9. 测试与验收标准
### 9.1 自动化测试
1. Provider registry 接受 `app_server_skills`,且 Tutti descriptor 使用该值。
2. skills-only fake app-server 只收到:
- `initialize`
- `initialized`
- `skills/list`
3. Codex 仍收到原有四个 catalog RPC。
4. Tutti `skills/list` fixture 映射出:
- `kind=skill`
- `trigger=$name`
- `path`
- `invocation=promptItem`
5. `enabled=false` 不进入 AgentGUI available skills。
6. app-server 失败时 Composer Options 仍成功且 catalog 为空。
7. 现有 AgentGUI 测试证明 `$` skill capability 可见并生成 structured skill
block。
执行:
```text
相关 providerregistry Go tests
services/tuttid/service/agent capability catalog tests
services/tuttid/api composer-options tests
AgentGUI capability/skill submission 回归测试
pnpm check:agent-provider-strategy-boundaries
pnpm check:changed
```
### 9.2 人工验收
2026-07-30 已使用本 worktree 的 `make dev-gui` 完成本机验收:
- Composer 当前 Provider 为 Tutti Agent。
- 输入 `$` 展示 49 个 skills。
- 输入 `$skill-cre` 只剩 `skill-creator`,选择后生成的编辑器 token 保留
`trigger="$skill-creator"` 和 `data-agent-mention-kind="skill"`。
- 输入 `/skill-cre` 不展示 `skill-creator`。
- 验证未发送消息,结束后已清空 Composer 草稿。
- 选择 `local:tutti-agent` 后 Composer 状态为 ready。
- Composer Options 的 `capabilityCatalog` 中存在 available skill,字段包含
`$trigger`、path、`invocation=promptItem`。
- `skills` 数组可以继续为空;AgentGUI 从 capability catalog 投影 skills。
- 输入 `$` 能检索 Tutti skills。
- 输入 `/` 不展示 skills。
- 选择并发送后,`turn/start.input` 包含结构化 skill block。
- 日志或 fake server 证明没有调用 app/plugin/MCP。
- Codex native plugins 的展示与调用保持不变。
## 10. 分阶段实施顺序
1. 增加 `CapabilityCatalogKindAppServerSkills` 和 Tutti descriptor 配置。
2. 给现有 lister 增加 skills-only request set,并补 Codex/Tutti RPC 测试。
3. 验证 Composer capability projection、缓存和错误降级。
4. 跑 API、AgentGUI、runtime 既有链路回归测试。
5. 使用发布版 Tutti Agent `0.0.10` 做端到端验收;本机已完成,其他平台
artifact 仍待验证。只有发现真实环境差异时再补最小环境适配。
## 11. 复杂度结论
本方案只新增一个 descriptor 枚举和一个 lister 请求范围,复用现有
Codex 的其余链路。
不需要前一版提出的:
- 新 skill discovery framework;
- skill DTO 扩展;
- Activity Adapter 修改;
- AgentGUI `/` 改造;
- Codex skill 来源迁移;
- 新 cache;
- 新 endpoint。
因此生产代码改动预计集中在 provider registry 和
`codex_capability_catalog.go` 两个区域,属于对现有链路的窄扩展,不是新增
一套 Tutti 专用系统。
@@ -0,0 +1,303 @@
# Codex 省额度模式验收记录(2026-08-03)
> This Codex-specific Luna subagent mode remains a supported, independent
> composer option. Provider-neutral, Session-scoped RTK saver mode is an
> additional option for every Agent provider; it does not replace this mode or
> install anything globally. See `docs/architecture/agent-runtime-preparation.md`.
> 目标:用户开启输入框旁的单一开关后,主线程继续使用用户选择的 Codex 主模型,适合拆分的独立子任务默认交给 `gpt-5.6-luna`(`max` 推理),并跨新会话与应用重启记住开关状态。
## 结论
**功能链路、桌面 UI 与成本感知路由验收通过;质量等价、固定节省比例和提速不作为承诺。** 真实 Tutti Desktop、tuttid、Codex provider 和模型调用链已完成入口展示、开启态、持久化、真实子线程模型和关闭态对照验证。早期“复杂任务默认至少两个 Worker”的规则使自然任务成本增加 4.9%,已被否决。最终规则改为只在独立工作能替换主线程工作时委派,并在一个三仓只读审计中将整条工作流 API 等价成本降低 54.9%,在一个同仓双问题复杂编码任务中降低 22.7%;两组质量证据相当,但后者耗时增加 62.1%。单一强耦合任务和可由一个阻塞命令完成的机械流程不再为了使用 Luna 而派发。此前 SWE-bench 隐藏测试仍显示不同任务存在质量波动,因此当前证据不支持“效果评分一样”“每项固定节省 1/10”或默认开启。macOS 系统录屏权限不可用时,使用 Electron `webContents.capturePage()` 仅截取 Tutti 自身窗口;会话验收附件中同时保留已有会话只读态与重启后新会话可操作开启态截图。
## 执行信息
| 项目 | 内容 |
| ------------ | -------------------------------------------------------------------------- |
| 分支 | `feat/codex-saver-mode` |
| 桌面环境 | Tutti Desktop dev build,`make dev-gui` |
| 本地入口 | `http://127.0.0.1:5173/` |
| Workspace | `ac379c4a-5c34-4751-bd41-a29b1fa51446` |
| Agent target | `local:codex`(可访问 model-plan) |
| 主模型/推理 | 单任务 `gpt-5.6-sol / low`;多任务 `gpt-5.6-sol / medium` |
| Luna 子线程 | `gpt-5.6-luna` / `max` |
| 证据优先级 | Codex `state_5.sqlite` > Session/Preferences API > 会话生成文件 > 模型回复 |
## 场景清单
| ID | 场景 | 操作与通过标准 | 结果 |
| --- | ---------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| S01 | 开发者入口控制 | `lab.codexSaverMode` 关闭时不展示输入框开关;开启后仅支持该能力的 Codex target 展示。 | ✅ 自动化测试、真实 Preferences/Composer API 与桌面截图通过。 |
| S02 | 单一输入框开关 | 输入框旁只出现一个“Codex 省额度模式”开关,不增加模型/role/线程数选择。 | ✅ 真实桌面截图显示单一开关;已有会话为只读,新会话可操作。 |
| S03 | 目标级记忆 | 对 `local:codex` 开启后,新会话与应用/daemon 重启后仍为开启。 | ✅ 完整重启后 API 均为 `true`;新会话 DOM 为 `checked`、`disabled=false`,截图显示紫色开启态。 |
| S04 | 主模型不变 | 开启省额度模式后,新建会话仍使用用户选择的 `gpt-5.6-sol` / `low`。 | ✅ Session API 与 Codex 状态库一致。 |
| S05 | Luna 配置注入 | 开启后,会话级 `CODEX_HOME/agents/luna_worker.toml` 为 Luna/max,并向会话 `AGENTS.md` 注入轻量路由规则。 | ✅ 文件真实存在;未修改用户全局 `$CODEX_HOME`。 |
| S06 | 真实子线程切模 | 给主线程一个边界明确、可独立执行的任务;主线程调用 `spawn_agent`,子线程实际为 Luna/max。 | ✅ 最终代码会话 `ce09a5f5-17a3-4aaf-aee7-1ffe30eecf02` 完成,1 个子线程;状态库为 Luna/max。前一诊断会话确认模型按版本无关提示选择当前 schema 的 `fork_turns=none`。 |
| S07 | 关闭态兼容 | 显式关闭后执行同类子线程任务,不生成 Luna role,不注入 Saver 规则,子线程继承主模型。 | ✅ 会话 `48d15900-9423-4d95-9923-a14853b8ca9e` 完成;主/子均 Sol/low,role 文件不存在。 |
| S08 | 活跃会话保护 | 已启动会话中不允许热切换该设置,避免运行时配置与 UI 状态不一致。 | ✅ service 与 AgentGUI 定向测试通过。 |
| S09 | 提示词成本感知且跨版本 | 只在独立单元能够替换主线程工作时委派;默认一个,多个真正独立单元才多开;不硬编码 V1/V2 参数或并发配置。 | ✅ runtimeprep 测试确认无 `fork_turns`/`fork_context`、无 `max_concurrent_threads`;真实 A/B 同时覆盖不派发、单 Worker 和多 Worker。 |
## 关键链路证据
### 1. 主模型保留,子线程实际使用 Luna/max
![主线程与 Luna 子线程权威状态库证据](assets/codex-saver-mode-runtime-evidence.png)
权威查询来自会话级 `CODEX_HOME/state_5.sqlite`:
```text
thread_source model reasoning_effort
------------- ------------ ----------------
(main) gpt-5.6-sol low
subagent gpt-5.6-luna max
```
这里不采用模型回复中的“我正在使用 Luna”作为通过依据。
### 2. 开关跨应用重启记忆
![省额度模式目标级记忆证据](assets/codex-saver-mode-memory-evidence.png)
应用和 daemon 完整重启后,真实 API 返回:
```text
preferences.featureFlags.lab.codexSaverMode = true
preferences.agentComposerDefaultsByAgentTarget.local:codex.codexSaverMode = true
composer.codexSaverModeSupported = true
composer.effectiveSettings.codexSaverMode = true
composer.effectiveSettings.model = gpt-5.6-sol
```
### 3. 关闭态恢复原行为
![省额度模式关闭态对照证据](assets/codex-saver-mode-disabled-evidence.png)
关闭态会话仍可正常调用子线程,但不会生成 `agents/luna_worker.toml`,主线程与子线程均保持 Sol/low。
### 4. 同任务开启/关闭 A/B(效果、耗时与成本)
2026-08-03 使用相同主模型 `gpt-5.6-sol / low`、相同提示词和相同验收答案,分别创建开启与关闭会话。任务固定只创建 1 个无历史子线程,计算并复核 `1² + 2² + … + 100² = 338350`。模型与 token 均取自会话级 Codex `state_5.sqlite` 和 rollout 原始事件,不采用模型自报。
| 指标 | 开启省额度模式 | 关闭省额度模式 |
| ----------------------------- | ----------------------------------------------- | ---------------------------------------------- |
| 主线程 | Sol / low | Sol / low |
| 子线程 | Luna / max | Sol / low |
| 正确性 | 主/子答案均正确 | 主/子答案均正确 |
| 子线程 token | 16,424(输入 16,224,其中缓存 5,888;输出 200) | 16,157(输入 16,104,其中缓存 9,984;输出 53) |
| 主线程 + 子线程 token | 66,252 | 65,833 |
| 子线程耗时 / TTFT | 8.320s / 8.019s | 7.124s / 6.911s |
| 整个主 Turn 耗时 | 20.622s | 18.293s |
| API 等价估算:子线程 | 约 $0.0024 | 约 $0.0372 |
| API 等价估算:主线程 + 子线程 | 约 $0.0672 | 约 $0.0929 |
本次单样本中,Luna/max 子线程 token 略多且约慢 16.8%,但按 OpenAI 2026-07-30 公布的 API 单价([Sol](https://developers.openai.com/api/docs/models/gpt-5.6-sol):输入/缓存/输出分别为 $5/$0.50/$30 每百万 token;[Luna](https://developers.openai.com/api/docs/models/gpt-5.6-luna):$0.20/$0.02/$1.20)折算,子线程约便宜 93.5%,整条工作流约便宜 27.7%。这说明该模式的主要收益来自 Luna 的更低计价,而不是保证减少 token 或降低延迟。
上述美元数是 **API 等价估算,不是本次 Pro/Codex 订阅的实际扣费**。估算只计算 rollout 可区分的未缓存输入、缓存读取和输出,未计无法从该记录单独识别的 cache-write surcharge。OpenAI 明确说明 Codex 订阅价格和 quota budget 不变,Luna 会消耗更少 credits;本次运行前后 UI 只提供整数百分比额度,均显示 90% 剩余,粒度不足以测出单任务实际 credit 差值。单次简单算术任务也不能代表复杂代码任务,应通过多任务、多次重复的质量/成本评测再决定默认开启范围。
公开资料:
- [OpenAI:GPT-5.6 price-performance 更新](https://openai.com/index/advancing-the-price-performance-frontier-with-gpt-5-6/) 给出的推荐编码链路正是 Sol 处理不确定性和规划,Luna 执行定义清晰的实现、测试和评估;同时说明 Luna API 降价 80%,Codex 中会消耗更少 credits。
- [OpenAI 在 X 的价格公告](https://x.com/OpenAI/status/2082878156483219672) 说明 Luna 降价 80%,并将更低价格反映到 Codex/ChatGPT Work 的 usage 计算。
- [Viv 的 X 讨论](https://x.com/Vtrivedy10/status/2083197691429863687) 指出 Luna 并非 Codex Multi-Agent V2 原生推荐的协作子代理,建议将其作为独立 Thread 运行;其讨论中也有人报告“Luna Max 主线程 + Sol 顾问 Thread”用量更低,但属于个人单次经验。
- [Eric Provencher 的 X 提醒](https://x.com/pvncher/status/2083300990350954981) 不建议修改 model catalog 强行开放 Luna,并认为需要主动代理间通信的任务仍应使用 Sol/Terra。本实现不修改 catalog,且提示词只把边界清楚、可独立验收的任务交给 Luna。
- [社区价格/基准对比](https://x.com/_codemeow/status/2084095080705741153) 称 Luna/max 与 GPT-5.4/xhigh 在 Artificial Analysis 上同为 51 分、价格低 92%;这是社区转述,不作为本功能验收的权威质量证据。
目前未找到针对“Sol 主线程 + Luna/max 独立子线程 + 本开关实现”的公开、可复现受控评测,因此公开讨论只能作为路由策略参考,不能替代本地 A/B 和后续业务任务评测。
### 5. 真实仓库复杂任务:Django #30179 / SWE-bench Lite
为避免把简单算术误当成复杂项目能力,追加了一个真实历史仓库任务。评测方法参考 [SWE-bench 官方说明](https://www.swebench.com/SWE-bench/):固定仓库和 base commit,仅向模型提供 issue,模型完成后再应用未向模型暴露的测试补丁,并分别统计 `FAIL_TO_PASS` 与 `PASS_TO_PASS`。真实工单为 [Django #30179](https://code.djangoproject.com/ticket/30179),官方标记 `Easy pickings: 否`,历史上有 24 轮讨论,问题涉及多列表依赖图、稳定排序、去重、循环冲突告警和兼容输出。
| 项目 | 内容 |
| ------------------ | ------------------------------------------------------------------------------------ |
| SWE-bench instance | `django__django-11019` |
| 仓库 | `django/django` |
| 固定 base commit | `93e892bb645b16ebaf287beb5fe7f3ffe8d10408` |
| 隐藏验收 | 16 个 `FAIL_TO_PASS`,58 个 `PASS_TO_PASS` |
| 主模型 | 两组均为 `gpt-5.6-sol / low` |
| 提示词约束 | 不允许联网查上游修复;受控 A/B 均要求只委派 1 个只读分析子任务,主线程负责实现和测试 |
| 隔离 | 开启、关闭、自然触发分别使用独立 git worktree |
评测分为两层:
1. **自然触发**:只提供真实 issue,不强制委派。开启模式后主线程自行完成,未创建任何子线程;这符合“强耦合任务留在主线程”的轻量路由策略,但该次运行没有产生 Luna 节省。
2. **受控 A/B**:同一提示词要求把一个边界明确的只读分析子任务交给子代理。开启组实际为 Luna/max,关闭组实际为 Sol/low;模型身份均从会话级 `state_5.sqlite` 与 rollout 事件读取。
| 指标 | 自然触发开启 | 受控开启 | 受控关闭 |
| ------------------ | -----------: | ---------: | ---------: |
| 主线程 | Sol / low | Sol / low | Sol / low |
| 子线程 | 无 | Luna / max | Sol / low |
| 总耗时 | 99.356s | 300.900s | 172.378s |
| 子线程耗时 | - | 184.731s | 67.845s |
| 主线程 token | 239,731 | 735,592 | 584,356 |
| 子线程 token | - | 381,368 | 165,354 |
| 总 token | 239,731 | 1,116,960 | 749,710 |
| API 等价总成本 | 约 $0.3585 | 约 $0.7592 | 约 $0.8958 |
| `FAIL_TO_PASS` | 4 / 16 | 4 / 16 | 4 / 16 |
| `PASS_TO_PASS` | 58 / 58 | 58 / 58 | 58 / 58 |
| SWE-bench resolved | 否 | 否 | 否 |
受控开启组的 Luna 子线程 API 等价成本约 $0.0240,关闭组 Sol 子线程约 $0.2474,子线程成本下降约 90.3%;但开启组总耗时增加约 74.6%,总 token 增加约 49.0%。由于 Luna 更低的单价抵消了额外 token,整条受控流程的 API 等价成本仍下降约 15.2%。这些美元数仍是按公开 API 单价折算,不是 Pro/Codex 订阅的实际扣费。
质量上两组没有差异:两份实现都通过了代理自己编写的测试和 640 个可见 `forms_tests`,但隐藏测试揭示它们都错误地保留了单声明 tuple/dict 表示、选择了与目标行为不同的稳定拓扑顺序,并保留了不符合目标格式的冲突告警。因此模型自测通过不能作为任务完成依据,最终以隐藏测试为准。
编排上,Luna 子线程在产出 `task_complete` 后,主线程连续执行等待且约 47 秒没有自动恢复;发送一次运行中 guidance 后主线程继续。关闭组的 Sol 子线程没有复现该问题。该现象是单样本,尚不能断言只由 Luna 导致,但在扩大灰度前必须补充多次重复测试和超时/完成态恢复保护。
本次在本机 Python 3.8 环境按 SWE-bench harness 的关键顺序等价执行:应用模型补丁、将测试文件恢复到 base commit、应用隐藏 `test_patch`、运行对应测试模块。由于当前 Docker 可用磁盘不足以满足官方建议的约 120GB,本次没有构建完整 SWE-bench Docker image;仓库提交、隐藏补丁和测试集合均来自官方数据集。这一环境差异应在 CI 或独立评测机上再复跑确认。
### 6. Sol/medium 三任务补测
为避免继续依赖单样本,固定主线程为 `gpt-5.6-sol / medium`,追加 3 个 SWE-bench Lite 真实任务。每组使用相同 issue 提示词并要求恰好 1 个只读分析子任务;模型完成后保存补丁,将测试文件恢复到固定 base commit,再应用官方隐藏 `test_patch`。Saver 开启组由当前产品 `runtimeprep` 生成会话级 `CODEX_HOME`,实际配置为主线程 Sol/medium、子线程 Luna/max;关闭组主、子线程均由 rollout 证实为 Sol/medium。
| 任务 | 复杂点 | 关闭组隐藏验收 | Saver 开启隐藏验收 | 关闭成本 | 开启成本 | 关闭耗时 | 开启耗时 |
| ---------------------- | -------------------------------------- | -------------- | ------------------ | ----------- | ----------- | ---------- | ----------- |
| `django__django-11019` | 媒体依赖图、稳定排序、冲突兼容 | 4/16,未解决 | 4/16,未解决 | $1.5024 | $1.0253 | 355.6s | 551.5s |
| `django__django-16820` | 迁移优化器、操作归约、旧索引语义 | 7/7,已解决 | 4/7,未解决 | $1.8533 | $1.6405 | 288.6s | 496.8s |
| `django__django-17051` | bulk upsert、RETURNING、数据库能力兼容 | 5/5,已解决 | 5/5,已解决 | $1.0905 | $0.7190 | 165.8s | 234.0s |
| **合计** | | **16/28;2/3** | **13/28;1/3** | **$4.4462** | **$3.3848** | **809.9s** | **1282.4s** |
两组 `PASS_TO_PASS` 均为 300/300,说明没有发现既有行为回归。Saver 开启组 API 等价总成本下降约 23.9%,但总 token 为 8,578,571,对照组为 3,674,332;主线程累计耗时增加约 58.3%。Luna 子线程三任务合计成本约为对应 Sol/medium 子线程的 15.0%,单任务范围约为 6.0%~26.0%,所以“约 1/10”只能作为当前定价和典型用量的简化说明,不能承诺每项任务的实际消耗固定为 1/10。
这轮还观察到 2 次小于工具下限的 timeout 参数、1 次命令进程创建重试,以及媒体任务子线程完成后的额外等待。模型均自行恢复,但说明 Luna/max 在复杂分析子任务上的 token、时延和编排稳定性仍需优化。
限制:`tutti agent start` 当前未暴露 `codexSaverMode` 参数,直接由 CLI 启动的会话明确不存在 `luna_worker.toml`,因此被保留为关闭组;开启组使用同一版本产品 `runtimeprep` 生成真实 Saver 配置后通过 Codex CLI 执行。两组提示词、base commit、主模型和隐藏测试一致,但启动外壳不完全相同;正式结论仍应在 daemon 暴露等价启动参数后复跑。三个任务并行执行,因此绝对耗时只用于同轮观察,不能与之前串行单任务直接比较。
### 7. 同一复杂任务的 3 子代理受控 A/B
在同一个 `django__django-16820` base commit 上追加一次多子代理实验。两组使用同一提示词、同一 `gpt-5.6-sol / medium` 主线程和两个隔离 worktree,并要求在主线程写代码前并发启动恰好 3 个无历史、只读子任务:实现语义分析、隐藏边界对应的测试矩阵、兼容风险复核。关闭组 3 个子线程均由 rollout 证实为 Sol/medium;Saver 组 3 个子线程均为 Luna/max。主线程汇总后独立实现,模型自写测试随后被恢复,再应用模型未见过的 SWE-bench 隐藏测试补丁判分。
| 指标 | 3×Sol/medium 对照组 | 3×Luna/max Saver 组 |
| ----------------------- | ------------------: | ------------------: |
| 主线程 | Sol/medium | Sol/medium |
| 子线程 | 3 × Sol/medium | 3 × Luna/max |
| 隐藏 `FAIL_TO_PASS` | 1 / 7 | 5 / 7 |
| 隐藏 `PASS_TO_PASS` | 300 / 300 | 300 / 300 |
| 完整解决 | 否 | 否 |
| 输入 token | 2,895,475 | 16,907,604 |
| 其中缓存输入 | 2,673,408 | 16,324,608 |
| 输出 token | 28,942 | 72,073 |
| 总 token | 2,924,417 | 16,979,677 |
| 主线程 API 等价成本 | $1.4609 | $1.8878 |
| 3 个子线程 API 等价成本 | $1.8544 | $0.4541 |
| 工作流 API 等价总成本 | $3.3153 | $2.3419 |
| 主 Turn 耗时 | 598.5s | 810.5s |
成本仍按 Sol 输入/缓存输入/输出 `$5/$0.50/$30`、Luna `$0.20/$0.02/$1.20` 每百万 token 计算。Saver 组虽然总 token 是对照组约 **5.81 倍**,但 3 个 Luna 子线程成本仍比 3 个 Sol 子线程低约 **75.5%**,整条工作流低约 **29.4%**。由于 Saver 主线程输入也被更长的等待和汇总放大,主线程成本反而高约 **29.2%**;Luna 的低单价并不等于多代理工作流会按固定比例省钱。
质量上,本次 Saver 组隐藏修复点明显高于对照组(5/7 对 1/7),但两组都没有完整解决任务,且单次结果与前一轮“1 个子代理”实验方向不同,不能据此宣称 Luna 质量稳定高于 Sol。耗时方面 Saver 组增加约 **35.4%**。其中一个 Luna 子线程没有产生 `task_complete` 事件,token 记录持续到主线程结束;另两个 Luna 子线程分别约运行 667s 和 315s,说明多个 Luna/max 并发时仍存在过度分析和完成态收敛风险。
实验材料保存在 `/Users/wwcome/work/evals/sol-medium-3agents/`:统一提示词、两组完整 JSONL、模型原始补丁、隔离 worktree 和应用隐藏测试后的失败现场均已保留。本次观察到 Saver 组部分隐藏修复点覆盖更高,但不能把差异归因于 Luna 或多视角;该补测支持“多 Luna 子线程仍可降低 API 等价成本”,不支持“效果评分一样”“固定 1/10 消耗”或默认无条件拆成多个子代理。
### 8. 零调度提示过度派发 A/B(历史失败,规则已否决)
为验证用户无需在任务提示中主动要求子代理,继续复用 `django__django-16820` 固定 base commit。两组使用完全相同的自然任务描述和 `gpt-5.6-sol / medium` 主线程;提示词中不含 `agent`、`subagent`、`Luna`、`spawn`、`parallel`、`子代理` 或 `并行`。关闭组没有 Saver role 或路由规则;开启组只通过产品 `runtimeprep` 注入当时版本的 Saver `AGENTS.md` 和默认 Luna role。
首版规则能够自动触发 1 个 Luna/max,但主线程重复执行了其侦察范围,工作流约 $1.5639、耗时 440.81s,相比关闭组成本和耗时都更高。随后用于验证自动多派发的规则把复杂编码任务默认拆成两个 Worker,分别负责源码/实现语义和测试/验证/兼容风险;该规则能够触发多个 Luna,但不符合省额度目标,已被最终成本感知规则替换。
| 指标 | Saver 关闭组 | 历史自动多派发组 |
| ------------------------ | -----------: | ---------------: |
| 用户提示中的调度词 | 0 | 0 |
| 主线程 | Sol/medium | Sol/medium |
| 自动创建子线程 | 0 | 2 |
| 子线程模型 | — | 2 × Luna/max |
| 主线程输入 token | 1,531,889 | 1,380,307 |
| 主线程缓存输入 token | 1,458,688 | 1,296,128 |
| 主线程输出 token | 10,876 | 9,122 |
| 两个 Luna 子线程总 token | — | 4,066,790 |
| 主线程 API 等价成本 | $1.4216 | $1.3426 |
| 子线程 API 等价成本 | $0 | $0.1485 |
| 工作流 API 等价总成本 | $1.4216 | $1.4911 |
| 主 Turn 耗时 | 343.75s | 636.51s |
| 最终可见 migrations 测试 | 721,跳过 2 | 722,跳过 2 |
运行证据确认两个子线程由同一 Sol 父线程在 15.8 秒内先后创建,实际模型均为 `gpt-5.6-luna / max`;主线程随后等待结果,没有先执行相同的仓库检索或代码修改。因此“无需用户提示即可自动触发多个 Luna”验收通过。主线程成本下降约 **5.6%**,但两个 Luna 的成本抵消了这部分收益,整条流程成本仍增加约 **4.9%**,耗时增加约 **85.2%**。该样本证明自动路由生效,不证明每个复杂任务都能省钱或提速。
这轮不用于质量横评:一个子线程误读了 base commit 之后的本地 Git 历史,主线程发现后未直接接受其结论,并使用完整可见测试重新核验,但样本仍存在信息污染。同时,两个写任务通过互斥文件范围在共享 worktree 中并行,而不是各自创建隔离 worktree;本轮证明了自动派发和模型选择,没有证明并行写入隔离策略。实验提示词、三轮 JSONL、独立 Codex Session rollout、耗时和 worktree 保存在 `/Users/wwcome/work/evals/sol-medium-auto-routing/`。
### 9. 最终成本感知规则的多场景 A/B
最终规则不再把“复杂”或“实现加测试”直接等同于多 Worker,而是要求 Luna 真正替换一段主线程工作:一个完整独立单元默认一个 Worker;只有多个非重叠大单元才多开;一个阻塞或事件驱动命令能完成的机械流程留在主线程。所有提示词均不包含 `agent`、`subagent`、`Luna`、`spawn`、`parallel`、`子代理` 或 `并行`,主线程固定为 `gpt-5.6-sol / medium`。
| 场景 | 关闭组 | 最终 Saver 组 | 路由行为 | API 等价成本 | 耗时 | 质量证据 |
| ------------------ | ------- | ------------------ | --------------------------------------------- | ------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------- |
| 三仓独立只读审计 | 1 × Sol | 1 × Sol + 3 × Luna | 三个非重叠仓库各一个 Worker | `$0.8348 → $0.3766`,降低 **54.9%** | `242.74s → 241.26s` | 两组均正确定位三个根因、文件和测试边界;六个 worktree 均 clean |
| 同仓双问题复杂编码 | 1 × Sol | 1 × Sol + 2 × Luna | 迁移优化器与 bulk upsert 分开执行,主线程汇总 | `$1.7312 → $1.3381`,降低 **22.7%** | `438.80s → 711.15s` | 两组均修改相同五类文件;迁移 optimizer/autodetector 与 bulk_create 测试通过,`git diff --check` 通过 |
| 单一强耦合编码 | 1 × Sol | 1 × Sol | 不为“复杂”强行创建 Worker | 无可归因的 Luna 节省;没有子线程新增成本 | 单样本存在模型波动 | Media 与 bulk_create 两个独立复跑均完成可见测试 |
| 三仓机械测试矩阵 | 1 × Sol | 1 × Sol | 一个 bounded 流程留在主线程 | 不再产生 Luna 编排成本;跨轮 Sol 成本不作归因比较 | 最终开启组 119.34s | 精确执行三条命令;一条按原命令缺少 `PYTHONPATH` 失败,两条通过;不修环境、不重复、仓库 clean |
成本仍按 Sol 未缓存输入/缓存输入/输出 `$5/$0.50/$30`、Luna `$0.20/$0.02/$1.20` 每百万 token 折算。双问题 Saver 组中 Sol 为 `$1.1815`,两个 Luna 合计 `$0.1566`;三仓审计 Saver 组中 Sol 为 `$0.2710`,三个 Luna 合计 `$0.1055`。两组成本拆分均与“由 Luna 替换昂贵主线程工作,而非无条件增加代理”的机制一致;单次非确定性 A/B 不用于证明因果。
机械测试矩阵的前一版规则曾额外创建 1 个 Luna,使成本从约 `$0.0831` 增至 `$0.1013`,高 21.8%;最终规则复跑确认只保留一个 Sol 会话,结构性额外成本已消除。关闭组当轮遭遇 WebSocket TLS 失败并返回空最终消息,因此不同轮次的 Sol 绝对成本不用于宣称节省。
工具调用预算目前是提示词约束,不是 provider 运行时硬限额。三仓审计的三个只读 Luna 实际工具调用数高于建议的 8 次;双问题任务中主线程在收到足够证据后主动中断了两个仍在继续验证的 Luna,避免继续扩大。该机制降低了本轮总成本,但不能保证所有模型版本严格遵守预算;后续若 Codex 暴露原生 per-agent tool/turn limit,应优先改为运行时限制。
完整材料保存在 `/Users/wwcome/work/evals/codex-saver-v4/`,包括自然提示词、完整 A/B/复跑 JSONL、会话级 rollout、计时日志和隔离 worktree。
## 数据链路验收
```text
开发者开关 lab.codexSaverMode
│ 控制入口是否展示
▼
输入框 Codex 省额度模式开关
│ target 级默认值持久化
▼
Preferences → Composer Options → Create Session
│ codexSaverMode=true,主模型字段原样保留
▼
Host / daemon Session settings
│ normalize、stream payload、resume 均保留设置
▼
runtimeprep(仅会话级 CODEX_HOME)
├─ agents/luna_worker.toml:默认 subagent = Luna/max
└─ AGENTS.md:边界任务 + spawn_agent 无历史 fork + 主线程复核
▼
Codex 主线程(用户模型) → 独立子线程(Luna/max) → 主线程汇总
```
## 验证命令与结果
```bash
go test ./packages/agent/runtimeprep ./packages/agent/daemon/runtime ./packages/agent/daemon/hostadapter
```
结果:通过。
```bash
pnpm check:changed
```
结果:最终 34 个 lane 全部通过(失败 lane 修复后复跑:6 个实际执行、28 个复用已通过结果)。独立 reviewer 已复查 settings round-trip、Codex V1/V2 no-history 兼容、默认 role 覆盖、TOML 合并边界和提示词重量;其提出的问题均已修复并补回归,最终复审 PASS、无阻断项。
## 已发现并修复的问题
1. Session settings 在 daemon runtime normalize 与 stream payload 往返时曾丢失 `codexSaverMode`,表现为创建请求为 `true`,随后读取变成 `false`。已补齐字段透传与回归测试。
2. 直接暴露 `agent_type` 会改变 Codex 保留工具 schema,当前 Sol 接口会以 400 拒绝,故未采用。
3. Codex V1 与 V2 的无历史参数不同(V1 为 `fork_context`,V2 为 `fork_turns`),且完整继承主线程时上游不会应用不同 role/model。最终方案把 Luna 配成省额度会话的默认子代理,并用版本无关的轻量 `AGENTS.md` 指示子任务不要继承主会话历史,由模型按当前工具 schema 选择对应选项。最终真实会话选择了 `fork_turns=none`。
4. 用户原配置若已定义 `agents.default`,会与自动发现的 Luna default 冲突。已改为只在省额度会话的隔离 `config.toml` 中显式声明 `agents.default → ./agents/luna_worker.toml`,不修改用户全局配置;标准表、quoted 表、`[agents]` 内联表、root dotted keys 及多行 description 均有冲突回归测试。
5. 应用重启后,旧 runtime 观测值曾覆盖创建时的不可变 Session 快照,使数据库仍为 `true` 的会话被 API/UI 错误显示为 `false`。已改为从 runtime snapshot 恢复 `codexSaverMode`;开启、关闭和缺少该字段的旧会话均有回归测试。重启最终开发版后,截图中的会话 `b8560ee5-0c85-42fa-9acd-f9e91193e3c3` 已由 Session API 正确返回 `true`。
6. Composer Options 进入 activity engine 时,clone 曾漏掉 `codexSaverModeSupported`,导致后端返回支持但 UI 展示层收到 `undefined`,输入框入口被隐藏。已补字段克隆和 reducer 回归测试。
7. 重启后服务端 `effectiveSettings.codexSaverMode=true` 曾未回填到新会话 UI/创建请求;同时稳定化比较把“未设置”和“显式关闭”都视为 `false`,可能吞掉用户 opt-out。已改为服务端默认值仅在本地三态值缺失时回填,并补充 authority=true 下无本地值、显式 false 两组展示与 activation 回归。
## 风险与待补
- 系统级 Accessibility 与 Screen Recording 权限仍未授予,因此无法使用桌面 CUA;本次截图只使用 Electron 自身窗口捕获,不包含其他应用或系统区域的交互验证。
- “不继承主会话历史”依赖主模型遵循会话指令;若模型忽略并使用完整历史 fork,子线程会按 Codex 上游规则继承主模型。真实验收任务已正确选择当前工具的 no-history 选项并切到 Luna/max。
- 该模式只改变适合独立委派的子线程,不保证每个任务都会拆分;轻量、强耦合任务继续由主线程处理属于预期行为。
- Luna 当前不是公开讨论中 Multi-Agent V2 原生推荐的主动协作模型。应继续限制为上下文自包含、结果可由主线程复核的独立任务;复杂跨代理协作、频繁互发消息和高风险决策仍留给 Sol/Terra。
- Sol/medium 三任务中,关闭组完整解决 2/3、Saver 开启组解决 1/3;当前不应宣传“效果评分一样”,也不建议默认开启。扩大灰度前仍需更多仓库、多次重复和完成态恢复保护。
- 受控开启组观察到一次子线程已 `task_complete`、主线程仍持续等待的恢复问题;需增加超时/终态同步验证,并判断是否为上游 Codex 编排问题。
- 工具调用预算目前只是提示词约束,不是 provider 运行时硬限额;模型可能超出建议值。主线程应在中间结果已满足验收条件时主动终止子线程,后续若 Codex 提供原生 per-agent tool/turn limit,应改用运行时限制。
- 默认 Luna Worker 不允许继续嵌套派发;只有父任务显式授权并给出嵌套 Worker 与工具调用总预算时才允许,以免递归放大成本。该边界目前同样由提示词执行,尚无 provider 运行时硬限制。
- 本次隐藏测试按官方顺序在本机 Python 3.8 等价执行,尚未在官方 Docker image 中复跑;CI/独立评测机需要补一次容器化复验。
- 开启态只写会话级 Codex home;关闭或新建关闭态会话不会遗留 Luna 配置,不需要数据迁移。
## 最终人工复验步骤
1. 在开发者设置打开“Codex 省额度模式入口”。
2. 新建 Codex 会话,确认输入框旁出现且只有一个“Codex 省额度模式”开关。
3. 选择任意 Codex 主模型并开启开关,发送一个可独立拆分的中等任务。
4. 确认主会话仍显示用户选择的模型,且能看到一个子线程完成后回到主线程汇总。
5. 新建会话并重启应用,确认开关仍为开启。
6. 关闭开关再新建会话,确认行为恢复且无 Luna 配置注入。
@@ -0,0 +1,340 @@
# Connector Market Shared Domain
Status: shared core, ordinary market delivery, Tutti daemon implementation
host, and renderer service implemented; exact package-cohort release and
downstream TSH pin remain rollout gates.
## Goal
Make Tutti the source repository for one connector-market capability that runs
inside both `tuttid` and the TSH desktop daemon. Both hosts share domain,
artifact, operation, recovery, local HTTP, and renderer semantics while keeping
independent durable state and runtime-specific adapters.
## Non-Goals
- Renderers do not call the remote Connector Market API.
- Shared modules do not own product endpoints, credentials, state-root
selection, OS integration, or runtime process policy. The canonical SQLite
schema belongs to `packages/connector/store-sqlite`; hosts select its path.
- Tutti does not infer missing release metadata from untyped raw JSON.
- This phase does not add placeholder daemon handlers or UI backed by fixture
success responses.
## Two API Contracts
### Remote Connector Market API
The Connector Market service owns the authoritative, versioned schema and its
generated protobuf/HTTP artifacts. Tutti pins those artifacts by provider
commit and SHA-256 in `packages/clients/market-go`, which exposes the reusable
Go client without importing the service application module. It exposes
published market items with immutable artifact keys, digests, and sizes.
Market-neutral connector `schemaVersion: "2"` declares one implementation.
Connector `schemaVersion: "3"` keeps the same release identity and listing
model while declaring an implementation matrix keyed by execution target.
Hosts retain v1/v2 read compatibility and normalize the selected v3 target into
the existing single-implementation runtime contract.
A release descriptor must bind at least:
- connector key and version
- immutable release identity and manifest digest
- artifact key, digest, size, and media type
- typed implementation kind and configuration schema version
- permissions and authorization kind
- product, platform, and minimum-host-version constraints
- publication metadata
Publishing is protected by the market service's existing request token.
Reading and downloading do not introduce a second workspace permission model.
The daemon resolves the returned artifact key against its configured artifact
base URL. Production uses the immutable Connector prefix on the public-assets
CloudFront distribution; the private `tsh-public-assets` bucket is only the CDN
origin and is never addressed by the daemon. The daemon verifies downloaded
bytes against the declared digest and size before installation.
The shared package may provide a default `CatalogSource` and artifact resolver
over the generated market client. It must not copy the remote schema into the
local daemon OpenAPI fragment.
### Local Daemon API
`packages/connector/market/openapi/connector-market.v1.yaml` is the shared
local daemon fragment. It exposes accepted catalog state, local installation,
authorization, compatibility, revisions, and durable operations to renderers.
An installed connector is daemon-global and available to every Agent and the
local Tutti CLI.
Each host composes this fragment into its aggregate OpenAPI document, generates
its own server and client, and provides transport mapping. The local daemon is
the authoritative source for renderer state.
Market placement is not part of the immutable release manifest. ZK owns each
listing's primary category, ranks, and optional featured placement; TSH exposes
the selected deployment market through category and cursor-paginated item
endpoints. Tutti forwards that model through daemon-owned category/page APIs.
The shared Valtio service maintains independent page state for every section,
and the renderer does not infer category membership from connector keys or
manifest JSON. `featured` is an overlapping collection, while authoritative
runtime refreshes enumerate every primary `category` section and deduplicate by
connector key.
## Package And Host Ownership
The shared Connector modules own:
- domain contracts, validation, state transitions, errors, and compatibility
semantics
- application orchestration, command idempotency, operation exclusion, and
recovery rules
- the default remote catalog domain adapter
- bounded artifact download, digest and size verification, safe extraction,
packaged-manifest verification, staging layout, atomic promotion mechanics,
cleanup, rollback, and reconcile rules
- the canonical SQLite repository, leases, migrations, transactions, and
durable outbox implementation behind Host ports
- ports for implementation hosting, credentials, scheduling, transport, and
diagnostics
- the local daemon OpenAPI fragment and Valtio renderer service
Each Host owns:
- the database path and lifecycle of the shared SQLite repository
- remote base URL, authentication, HTTP client, proxy, TLS, logging, and tracing
- state-root configuration
- implementation hosting and observation, including permission, process,
OS, and credential integration
- secure credentials and authorization callbacks
- durable outbox storage and integration with the existing event fanout
- generated local daemon handlers and renderer backend/event adapters
Default package implementations remain behind ports so special hosts can
replace environment behavior without replacing shared semantics.
## Artifact Preparation And Activation
`ArtifactPreparer` and the daemon `ImplementationHost` use immutable requests
and verifiable receipts. Their durable identity carries at least:
```text
operationId
connectorKey
version
releaseIdentity
releaseDigest
artifact key/digest/size/mediaType
validated manifest
```
The execution boundary is:
```text
operation executor
-> package ArtifactPreparer
-> prepared artifact and receipt
-> daemon ImplementationHost
-> generation-fenced MCP/CLI routes and observed process result
-> repository completion transaction and outbox
```
Package archive handling rejects absolute paths, parent traversal, symlink and
hardlink escape, and archives that exceed file-count, individual-size,
expanded-size, or compression-ratio limits. It validates redirects, content
type, timeout, declared size, digest, and the packaged manifest before any
artifact code can execute. Artifact download is an ordinary direct GET and does
not carry workspace identity.
Staging and active directories use the same filesystem when relying on atomic
rename. Updates preserve the previous active version until new activation is
observed. Install, update, uninstall, cleanup, and rollback are idempotent for
the operation and immutable release identity.
## Operation And Recovery Model
Execution is at least once because SQLite, filesystem mutation, runtime
activation, and process restart cannot form one atomic transaction.
The durable installation stages are:
```text
accepted -> downloading -> prepared -> activating -> completed
| | | |
+------------+------------+-------------+-> failed
```
The Repository stores lease owner, lease expiry, attempt, stage, immutable
release identity, and error details. Recovery inspects durable operation state,
staging and activation markers, and the Host runtime's observed state before
resuming a stage. A running operation is never blindly replayed from the
beginning.
`clientRequestId` has a database uniqueness constraint with a documented
scope. Artifact and activation work is idempotent for:
```text
operationId + connectorKey + version + releaseDigest
```
The operation executor, not the artifact preparer, owns repository state
transitions and event creation.
## Authorization Recovery
Authorization start is a recoverable operation. The provider uses the
operation or client request identity to return or resume one external
authorization session, and the callback completes authorization in a separate
fenced transaction.
A temporary synchronous implementation is allowed only if it cannot leave a
recoverable `running` operation after the request ends.
## Event Consistency
Connector state changes and invalidation events are inserted into a durable
outbox in the same SQLite transaction. The Host publisher forwards outbox
entries through its existing event stream and records progress.
Events carry monotonic revision or sequence. Reconnect supports replay from a
known sequence; a retention gap forces a full snapshot reload. Events never
replace the authoritative local snapshot.
Before durable replay is available, a transitional Host may use best-effort
events only with full reload on daemon reconnect, window resume, and command
completion. Renderer request-sequence, service-generation, and revision fencing
stay enabled.
## Pre-Release Breaking Transition
The current connector-market changes are not released, so the connector
publish, storage, and public read contracts are updated together instead of
maintaining a legacy connector payload. The outer market service may continue
to support other item types, but connector payload validation and projection
move directly to the initial typed release contract.
The coordinated transition is:
- change the connector publish manifest and validation in the market service
- regenerate the market service and consumer clients in the same delivery
sequence
- update the shared package mapper and manifest contract to the same
remote `schemaVersion: "2"`
- update or republish development connector fixtures; disposable local records
may be reset
- reject any remaining untyped connector record instead of dual-reading it
No connector v1 read path, legacy catalog adapter, synthesized field, or
production fallback is introduced. A failed or invalid remote refresh still
preserves the last-known-good accepted daemon catalog; network failure never
becomes an empty catalog.
## Renderer Integration
Each Host renderer injects backend and event adapters over its generated local
daemon client. `ConnectorMarketModule` creates a child DI container and runs
Market, UiState, and View StartupJobs through the shared lifecycle. The Market
job blocks `synchronizing` on the initial authoritative snapshot; View starts
in `materializing`; only `ready` exposes the Root to React.
The shared services own global Valtio state, command flow, revision fencing,
coalesced invalidation reloads, and the render-ready projection. A Host window
may activate the module from its workspace startup
flow for lifecycle purposes, but the connector scope and daemon runtime remain
global. React receives only the Root through stable context and does not create
clients, connect WebSockets, start loads, dispose services, or merge daemon
business state.
The shared renderer provides the settings catalog and one modal state machine:
unconnected connectors render authorization content, connected connectors
render management content, and blocked connectors render a blocked-state
dialog. There is no persistent details column.
Daemon reconnect performs a full reload. An accepted command is followed
through events or the operation endpoint until terminal state.
## Revised Delivery Sequence
1. Harden the shared operation, artifact, authorization, and event contracts;
add conformance scenarios for crash windows and idempotent replay.
2. Replace the current unreleased connector publish/read shape with the
authoritative `schemaVersion: "2"` release schema and regenerate its
clients.
3. Add the package default catalog adapter, artifact preparation engine, and
reconcile rules.
4. Implement `tuttid` SQLite persistence, leases, implementation hosting,
credentials, durable outbox, scheduler, and startup recovery.
5. Compose the local fragment, implement handlers, and regenerate Go and
TypeScript clients.
6. Register and activate the shared renderer module with Tutti's
generated-client and event adapters.
7. Add the shared connector-market UI to Tutti Settings > Agent > Connectors.
8. Publish one exact package release cohort.
9. Install the exact Go and npm versions in TSH and implement only its Host
adapters.
10. Remove disposable pre-release fixtures and temporary transition paths after
all repositories consume the initial contract.
## Host Integration Gate
Do not add the local fragment to a production daemon aggregate until all of the
following exist:
- an authoritative catalog source using the initial typed release contract
- real persistence and atomic revision transactions
- operation identity, lease, recovery, and terminal-state handling
- safe artifact preparation and a real implementation host for every installable
implementation
- authorization recovery for every advertised authorization kind
- event delivery with either durable replay or the documented transitional
refreshes
- handler, persistence, recovery, and renderer-adapter tests
## Current Implementation Checkpoint
The shared package includes:
- public package and release registration
- domain types, state transitions, manifest validation, ports, and an
application core with revision fencing, request idempotency, immutable
targets, execution receipts, per-connector exclusion, operation execution,
authorization recovery, leases, and restart recovery
- secure artifact download/preparation with archive safety limits, packaged
manifest verification, content addressing, and atomic promotion
- the shared local daemon OpenAPI fragment
- package-resolved fragment support for cross-repository hosts
- the Root/Runtime/lifecycle/StartupJob renderer module with Market, UiState,
and View Valtio services, stale-response fencing, refresh singleflight,
mutation locks, invalidation reload, and reconnect
reconciliation
- the reusable `@tutti-os/ui-system` catalog, toolbar, authorization,
management, and blocked-state dialogs plus scoped i18n resources
The Tutti Host includes:
- a typed remote catalog adapter for the TSH Connector Market endpoint
- SQLite persistence for metadata, accepted releases, operations, leases, and the
transactional changed-event outbox
- operation scheduling and startup recovery
- aggregate OpenAPI composition, generated Go handlers and TypeScript client
- a generated-client/event-stream Renderer adapter activated with the Host
window lifecycle while exposing one global connector domain
- the shared connector-market panel mounted in Settings > Agent > Connectors
Tutti now advertises the production `managed_stdio` implementation when its
platform/runtime constraints match. It reads the TSH market item API, downloads
the artifact directly from configured storage, verifies and prepares an
immutable artifact snapshot, and hosts MCP as a daemon-owned long-lived child
or CLI as a bounded one-shot Node/Python child. Routes and every child process
are global, identified by connector plus connection ID, and fenced by boot
epoch. Every Agent and the local Tutti CLI can discover and invoke installed
routes through the daemon CLI channel. Startup waits for a catalog refresh,
while later market outages preserve the installed last-known-good runtime
projection.
Authorization remains intentionally limited to `none`; connectors that require
credentials remain visible as unsupported until the daemon credential broker
lands. Durable event replay remains outside this checkpoint. The renderer
module and Tutti adapter are wired and use authoritative reloads on reconnect,
resume, and command completion; the UI presents daemon-authoritative
unsupported and blocked states instead of synthesizing installability.
@@ -0,0 +1,692 @@
# Agent 会话 Activity View PRD
Status: implemented; pending code review
- 日期:2026-08-05
- 产品范围:Tutti Agent GUI Conversation Rail
- 交付承载:Desktop Workbench、独立 Agent 窗口与 Native Mobile
- 共享边界:`@tutti-os/agent-gui`、`@tutti-os/agent-activity-core`、Conversation Rail runtime、Desktop daemon 实时会话投影
- 参考实现:Codex.app 左侧会话列表的 Activity View
- Golden reference:Codex.app `26.730.61639`(build `6234`),验证日期 2026-08-05
## 1. 摘要
为 Agent GUI 左侧 Conversation Rail 增加“Activity View”。用户开启后,列表不再以项目分区和单纯时间倒序为主,而是把需要用户处理、未读结果和正在运行的会话集中到列表顶部,再补充最近 7 个自然日的会话。
该功能不是对当前屏幕中的 DOM 行临时排序,而是对 Agent GUI 当前内存态中的 canonical Session 摘要做稳定投影。它复刻 Codex.app 的实际展示行为:
1. 开启时同步快照当前 Engine 已在内存中的可见**根会话摘要**;
2. 立即按 `waiting → unread → active` 的客户端 presentation policy 投影 Priority;
3. 同一份摘要集合中其余最近 7 个自然日的会话直接放入 Today、Yesterday 或 weekday 日期段;
4. 开启时快照 Priority 顺序与日期段 recency;开启期间监听 canonical 状态,但保留既有成员顺序,只按 Codex 规则增量合入新候选并更新行状态;
5. 开启 Activity View 本身不发起历史查询、补页或 transcript hydration;Desktop daemon 新推送或更新的会话先进入 Engine,再按实时规则增量入队。
展示范围以当前 Engine 内存态为准,而不是以 React 此刻挂载的 DOM 行为准。未加载进内存的历史会话不因开启 Activity View 而额外加载;用户本次没有加载、daemon 也没有实时推送的会话不在本功能的关注范围内。
Desktop 首期不改变普通 Rail 的项目分区、每次显示 5 条和“显示更多”逻辑。普通视图与 Activity View 是同一 Conversation Rail 的两种 presentation mode;Mobile 会话首页直接使用 Activity View,不再提供项目分组目录视图。
Activity View 是一个专用的列表展示模式,不是通用筛选器。本功能不新增筛选入口、筛选条件、筛选状态、组合规则或可复用的 filter API,也不把 Tutti 现有的 Agent target 选择能力纳入 Codex 体验对齐范围。Activity View 直接消费当前挂载 `AgentSessionEngine` 中的内存态 canonical sessions,不定义或改变上游数据语义。
### 1.0 Mobile adoption amendment(2026-08-06)
Native Mobile 会话首页从本次实现起固定使用 Activity View。Mobile 不再展示旧的 pinned / project / conversations 项目分组目录,也不提供会话 Pin 或 Unpin 操作。Priority 与最近 7 个自然日的分组、会话行的生命周期状态和稳定排序复用本 PRD 及 `@tutti-os/agent-gui` 的共享投影规则。
搜索仍然临时接管内容区,但由共享 Conversation Rail query controller 负责服务端分页查询,从而可以查找当前 Engine 尚未载入的历史会话;清空搜索后恢复 Activity View。该 Mobile 差异只改变承载视图和历史搜索入口,不改变 canonical session、attention/read 或 pin durable owner;Desktop 的普通 Rail 和 Activity View 行为不变。
Mobile Activity 会话行不显示单独的 `...` 操作按钮。普通点击进入会话,长按会话行打开重命名和删除操作列表。存在项目归属时,项目名以文件夹图标加项目名的形式显示在标题下方;没有项目归属时显示“对话”来源标识。
### 1.1 Codex 体验同构原则
Codex.app 当前正式安装版是本功能所有**可观察产品行为**的 golden reference。入口位置与形态、按钮状态、coachmark、菜单结构与默认值、分组、排序、空状态、批量操作、选中保留、滚动、实时更新与列表稳定策略、加载反馈、键盘与无障碍行为均默认逐项复刻,不由 Tutti 另行定义“更合理”的标准。
Tutti 可以采用不同的内部数据访问和状态实现,但不得改变用户可观察结果。只有满足以下条件时才允许体验差异:
1. Codex 原行为已经验证;
2. 差异来自 Tutti canonical 数据/权限边界,或经过产品明确批准的首期范围裁剪;
3. PRD 单列差异,写清 Codex 原行为、Tutti 特有事实或范围决策、最小差异方案和验收用例;
4. 差异经过产品与架构显式批准。
未完成上述记录和批准的行为一律回到 Codex 现行设计。内部技术限制、实现成本或个人偏好不构成体验差异理由;Codex 行为尚未查清时,结论是“待验证”,不是自由设计。
4.1 中已批准的首期裁剪是唯一交互例外;除此之外,排序、状态变化、空状态、搜索接管、选中保持、滚动、实时入队与列表稳定行为全部逐项对齐 Codex。
## 2. 背景与问题
当前 Conversation Rail 主要解决“会话属于哪个项目”和“最近使用了什么”两个问题:
- 后端按 `pinned`、项目和 `conversations` 分区返回会话;
- 每个分区首屏默认 5 条;
- 用户通过“显示更多”加载分区下一页;
- 分区内按会话 sort time 倒序,置顶区按 pinned time 倒序;
- 搜索可进一步缩小当前列表;host 侧如已有 Agent target 选择,它发生在数据进入 Rail 之前,不属于本 PRD。
这套结构适合浏览,但不适合同时运行多个 Agent 时的注意力管理。典型问题包括:
- 一个等待审批的会话可能沉在某个项目分区中;
- 多个项目同时运行时,用户需要逐区检查哪些 Agent 仍在工作;
- 已完成或失败但未读的会话容易被更新更近、但不重要的会话挤下去;
- 当前 DOM 通常只有每区 5 条,直接操作 DOM 会漏掉已经在 Engine 中但未挂载的会话,以及 daemon 刚推送的新会话;
- Message Center 已经具备关注项聚合,但它不是 Conversation Rail 的快速导航替代品。
Activity View 的核心价值是把 Conversation Rail 从“目录”临时切换为“工作队列”,帮助用户回答:
> 现在最需要我打开的是哪个 Agent 会话?
## 3. 产品目标
### 3.1 目标
1. 用户能在一次操作内看到当前列表上下文中需要处理、未读和运行中的会话。
2. 视图基于 Engine 当前全部内存态会话,而不是只读取当前 DOM 行。
3. 打开视图后立即完成本地投影,不为该视图额外等待网络查询。
4. 视图复用现有 canonical lifecycle 和 attention/read facts,不创建第二套会话状态。
5. 实时状态变化无需刷新即可更新行状态;成员与顺序按 Codex activation snapshot/incremental-merge 规则保持稳定。
6. Desktop 普通 Rail 的项目组织、搜索、置顶、会话操作和选择行为保持兼容;Mobile 会话首页固定使用 Activity View,不提供项目分组和置顶入口。
7. Desktop host 通过【实验室】中的 Activity View 开关选择性启用该能力,默认关闭;共享 `agent-gui` 的外部 host 仍可通过 runtime capability 关闭且不受行为影响。
### 3.2 非目标
- 不替代 Message Center 的摘要、批量处理和交互卡片能力;
- 不定义新的 Session、Turn、Interaction 或 Goal 生命周期;
- 不根据标题、消息文本或 provider 名称推测优先级;
- 不在开启视图时加载所有历史会话或所有 transcript;
- 不因开启视图发起任何 Session 列表补页、全历史查询或 secondary-content hydration;
- 不改变 Session pin、read/unread 的 durable owner;
- 不新增 Session archive 生命周期或 Activity View 批量删除入口;
- 不改变现有单条删除及其他页面已有的删除能力;
- 不在首期提供用户自定义权重、拖拽调整优先级或 AI 自动评分;
- 不新增或改造 Agent target、workspace、provider 等通用筛选器能力;
- 不把 Mobile 的旧项目分组目录继续作为会话首页;Mobile 采用本节已批准的 Activity View 承载差异;
- 不要求外部 AgentGUI host 实现、展示或对齐 Activity View;
- 不把普通项目分区永久替换成优先级分组。
## 4. 关键产品决策
| 编号 | 决策 | 理由 |
| ---- | ------------------------------------------------------------------- | ----------------------------------------------------------------- |
| D1 | 功能名称为 Activity View;Priority 是视图顶部的固定标题区段 | 不再把整个能力称为 Priority View |
| D2 | Desktop 默认关闭,由用户显式开启;Mobile 会话首页固定开启 | Desktop 保留既有项目目录心智;Mobile 直接采用已批准的新首页承载 |
| D3 | 最近范围固定为最近 7 个自然日 | 覆盖一周工作上下文,避免无边界加载历史 |
| D4 | Priority 顺序复刻 Codex:`waiting`、`unread`、`active` | 不按失败/成功再创造一套 Tutti 排序标准 |
| D5 | `waiting` 由根会话 canonical `needsUserAction` 映射 | 与普通 Rail 一致,包含后代 pending Interaction,但保留精确子身份 |
| D6 | 子 Session 不作为左栏独立行,其 lifecycle 不改变根行 lifecycle 状态 | 子 Agent 运行状态留在详情中;待处理提醒仍汇总到根会话 |
| D7 | Priority 与近期日期段之间去重 | 一个 Session 只在最靠前的可见位置出现一次 |
| D8 | 搜索临时接管内容区,清空搜索后恢复 Activity View | 搜索是明确查找意图,避免搜索结果与工作队列结构混杂 |
| D9 | Activity View 不新增或改造通用筛选器 | 当前 Engine 是完整输入边界,不增加 host context 契约 |
| D10 | Activity View 开启本身仅投影当前 Engine 内存态,不因开启视图补页 | Desktop Activity View 聚焦当前工作;Mobile 的显式搜索另走分页查询 |
| D11 | Activity View 不提供批量归档或批量删除入口 | Archive chats 映射为 Tutti 删除,但首期不纳入标题菜单 |
### 4.1 已识别的体验差异与数据映射
| Codex 行为/概念 | Tutti 特有事实 | 处理方式 | 是否改变体验 |
| --------------------------------- | -------------------------------------------------- | -------------------------------------------------------------- | ---------------------------- |
| 本地 task attention `waiting` | Tutti 用 pending Interaction / `needsUserAction` | adapter 映射为 waiting | 否 |
| 子 Agent 仍在工作或等待 | 子 Session 有独立 lifecycle 和 pending Interaction | lifecycle 留在详情;pending Interaction 汇总为根会话 attention | 是;状态与提醒分别投影 |
| `threadId` / `parentThreadId` | Tutti 用 `agentSessionId` / `parentAgentSessionId` | identity adapter,保留根行/子行关系 | 否 |
| ChatGPT 模式可筛 Work / Chat | Tutti Agent GUI 只有 Agent Session | 不显示无意义的 Work/Chat 选项 | 是;最小差异,数据类型不存在 |
| Scheduled 选项 | Tutti 没有 canonical scheduled Session 类型 | 不显示 Scheduled 选项 | 是;数据类型不存在 |
| Activity View options | 首期明确不纳入范围 | 不显示标题菜单;不实现 Pinned、Mark all as read 等操作 | 是;已批准的首期范围裁剪 |
| 首次使用 coachmark | 首期明确不纳入范围 | 不展示引导,也不保存“已看过引导” preference | 是;已批准的首期范围裁剪 |
| 前 9 个 Priority 快捷导航 | Tutti 没有 Rail 级会话快捷导航基础设施 | 首期不实现,也不新增全局快捷键 | 是;已批准的首期范围裁剪 |
| Codex `Archive chats` | Tutti 对应现有 delete 会话语义 | 复用现有单条删除;首期 Activity View 不新增批量入口 | 是;首期裁剪批量操作 |
| Codex 本地 catalog 已提供全局摘要 | Tutti 只保证 Engine 当前内存态 | 不补历史;对内存态会话投影并接收 daemon 实时增量 | 是;覆盖范围较窄 |
除本表外当前没有批准的体验差异。新增差异必须更新本表、验收标准和 parity 测试后才能实现。
## 5. 用户与使用场景
### 5.1 核心用户故事
- 作为同时运行多个 Agent 的用户,我希望先看到等待我批准或回答的会话,以便解除阻塞。
- 作为保持 Agent GUI 运行一段时间后返回的用户,我希望看到内存态中尚未阅读的完成和失败结果。
- 作为管理多个项目的用户,我希望跨项目看到仍在工作的会话,而不必逐个展开项目。
- 作为查找历史会话的用户,我希望搜索行为不因 Activity View 而改变。
- 作为键盘用户,我希望能快速切换视图并完整操作列表。
### 5.2 典型旅程
```mermaid
flowchart TD
A["打开 Agent GUI"] --> B["普通项目分区视图"]
B --> C["开启 Activity View"]
C --> D["立即投影 Engine 已知会话"]
D --> G["Priority / 近期日期段稳定展示"]
J["Desktop daemon 推送会话变化"] --> K["写入 Engine canonical state"]
K --> G
G --> H["选择会话"]
H --> I["正常 detail hydration"]
```
## 6. 术语与 canonical facts
### 6.1 术语
- **普通视图(Directory mode)**:现有 pinned、项目和 conversations 分区视图。
- **Activity View(Activity mode)**:本 PRD 新增的跨项目工作队列视图;顶部显示 Priority,其余近期会话直接按日期标题排列。
- **Priority**:Activity View 顶部的固定标题区段,只承载 waiting、unread 和 active 根 Session;不是整个视图的名称。开启后已经进入 Priority 的成员即使变为已读或 idle,也保留到下一次 toggle;这属于 activation snapshot retention,不是新的 idle admission 类型。
- **近期日期段**:由 Today、Yesterday 或 weekday 标题及其会话组成;产品模型和界面均不存在名为 Recent 的固定分组。
- **Desktop daemon**:host 向 Agent Engine 推送 canonical Session 变化的后台通道;Tutti 对应 `tuttid`,其他 Desktop host 可对应 `desktopd`。两者都必须先写入 Engine,再由 Activity View 投影消费。
- **Engine identity**:现有 `AgentSessionEngine.identity`,由 `(workspaceId, runtime origin)` 定位 Engine;Activity View 不新增或复制该 identity。
- **activation instance**:每次开启 Activity View 创建的 surface-local 快照实例;关闭时丢弃,重新开启时重建。
- **轻量会话数据**:Session、latest Turn、pending Interactions、attention/read marker、项目归属和标题等列表投影,不含完整消息历史。
- **根 Session**:`kind = root` 的顶层会话,是 Conversation Rail 的展示单位。
- **子 Session**:由另一个 Session 派生、`kind = child` 且带 `parentAgentSessionId` 的协作/子 Agent 会话;可独立读取,但不在 Rail 中单列,当前通过父会话中的活动卡片展开查看。
### 6.2 事实来源
| 产品事实 | canonical owner | 禁止的替代判断 |
| ------------- | --------------------------------------------------------- | --------------------------------------- |
| 需要用户处理 | pending `Interaction` / existing needs-attention selector | 从 transcript 文案或 `waiting` 字样猜测 |
| 正在运行 | active Turn phase 与现有 display status projection | 从 spinner、DOM 或更新时间猜测 |
| 未读完成/失败 | Engine attention/read state,按 completion key 记录 | 仅根据 latest outcome 推测 |
| 会话时间 | 现有 conversation sort time projection | 直接混用任意 updated timestamp |
| 项目归属 | canonical `railSectionKey` 和项目目录 | 从 `cwd` 反推 |
| 可见性 | plural consumer selector 与后端 visible projection | resumable 或收到事件就强制显示 |
## 7. 信息架构与交互
### 7.1 入口
在 Conversation Rail 顶部操作区放置与 Codex `View activity` 同构的 icon toggle:
- 默认状态:关闭;
- 关闭时语义与 Codex `View activity` 一致;有待关注项时 accessible label 带 needs-attention 语义;
- 开启时语义与 Codex `Turn off activity view` 一致;
- 使用 `aria-pressed`,关闭态为 ghost,开启态为 accent-subtle;
- 首期不展示 coachmark 或其他首次使用引导,也不创建对应的已读 preference;
- 只有 host runtime capability 显式启用且 Engine 提供 canonical Session 与 attention selectors 时才展示入口;
- capability 关闭或缺失时不展示入口,不提供只排序当前 DOM 行的降级版本;
- 首期不把前 9 个 Priority 会话接入快捷导航,不新增 Rail 级全局快捷键或 command-palette 入口。
外部 host 的能力开关采用 fail-closed 语义:
```ts
interface AgentGUIRuntime {
conversationActivityViewEnabled?: boolean;
}
```
- Desktop Workbench 与独立 Agent 窗口使用的 Desktop runtime 仍显式声明 `conversationActivityViewEnabled: true`,但最终入口还受【实验室】`lab.conversationActivityView` 开关控制,默认关闭;
- 外部 host 传入 `false` 或不提供 `conversationActivityViewEnabled` 时视为禁用;
- 有效启用条件为 runtime capability 严格等于 `true`;
- 禁用时不注册入口,不创建 activation;
- 若 capability 在视图已开启时变为 `false`,立即清理本次 activation 并恢复普通 Rail;普通 Rail 的既有请求和 Session durable state 均不受影响。
### 7.2 首期无标题菜单
首期不在 Priority 标题右侧提供 `Activity view options` 或其他标题操作入口,因此不实现 Pinned、Mark all as read、Restore defaults 和 Archive chats。Desktop 现有 pin/unpin、单条标记已读和单条删除能力仍在原有位置按原行为工作;Mobile Activity View 不提供 pin/unpin,仅保留单条会话操作和删除能力。Activity View 不新增批量操作。近期日期段始终显示,不提供“显示最近会话”开关。
Activity View 开启状态属于当前 surface 的临时状态。首期没有 Activity View 专属可持久化选项。滚动不创建 Activity View 专用位置,复用该 Rail mode 的现有 scroll state。
### 7.3 列表排列
最终展示顺序:
1. **Priority / 优先处理**
2. 其余最近 7 天会话直接按 **Today / Yesterday / weekday** 日期标题排列;不增加额外总标题
Priority 内部不再增加多层可折叠标题,避免窄 Rail 产生过多 chrome。会话行通过状态 icon、unread dot 和 accessible label 表达具体原因。
### 7.4 优先级排序规则
每个根 Session 先映射为 Codex 同构的 attention state,再计算纯 presentation rank:
| rank | Codex 同构状态 | Tutti canonical 映射 | 行内原因 |
| ---- | -------------- | ----------------------------------------------------------- | -------------------------------- |
| 0 | `waiting` | 根会话 `needsUserAction=true`,包含后代 pending Interaction | 需要处理 |
| 1 | `unread` | 现有 attention/read state 表示存在未读结果 | 未读;不再细分失败或成功的优先级 |
| 2 | `active` | 根 Session 自身仍有 active lifecycle projection | 进行中 |
普通 idle 不进入 Priority,由近期日期范围决定是否出现在某个日期段。Activity View 运行期间晚到的 canonical idle summary 也不会被视为 activity,不会改变 Priority;它只可在下一次 toggle 的初始快照中按近期日期进入日期段。仅因用户选择历史会话而临时注入的 idle summary 同样不属于 Activity candidate。已经进入当前 Priority snapshot 的会话即使随后标记已读或变为 idle,也保留原成员和相对顺序,直到下一次 toggle;删除 tombstone 是唯一的即时移除例外。
同 rank 内按现有 canonical recency 倒序。相同 recency 保留输入顺序;去重 identity 使用 exact `agentSessionId`。这与 Codex 的稳定排序策略一致,不额外用 Session ID 改变用户可见顺序。
当一个 Session 同时满足多个条件时只使用最小 rank。例如:
- 一个置顶会话正在等待审批:只进入 Priority;
- 一个未读会话同时位于最近 7 天:只进入 Priority;
- 一个普通置顶会话如果不满足 Priority 条件:仅在属于最近 7 天时进入对应日期段;置顶状态不会创建 Activity View 专属区段;
- 一个已读完成会话且在最近 7 天:进入对应日期段。
### 7.5 近期会话与日期段范围
- cutoff 为当前本地时区“今天 00:00”向前 6 天,即包含今天在内的最近 7 个自然日;
- 使用现有 canonical conversation sort time;
- 已在 Priority 展示的 Session 不再进入日期段;
- read failure、completed、canceled、ready/idle 和 imported Session 均可进入对应日期段;
- imported Session 不因历史导入自动生成 unread,但可以按真实 sort time 进入对应日期段。
### 7.6 子 Session / 子 Agent 行为
Codex.app 同时采用了“实体独立、导航聚合”的设计,Tutti 首期照此实现:
- 子 Session 有自己的 `agentSessionId`、`parentAgentSessionId`、状态和 transcript;当前可在父会话的子 Agent 活动卡片中展开查看;
- 普通 Rail 和 Activity View 都过滤 `kind = child` 的条目,不把子 Session 当成与根 Session 并列的会话行;
- 子 Session 的 working/waiting lifecycle 不覆盖根 Session 的 lifecycle 状态,根行仅在根 Session 自身运行时表现为 active;
- 子 Session 的 pending Interaction 通过 canonical root-attention selector 让根行表现为“需要处理”,但提交仍使用精确子 Session/Turn/Interaction 身份;
- 根行的标题和未读不通过拼接子 Session 文案推测,继续读取各自 canonical selector;
- 父会话协作详情沿用现有子 Session 加载机制;该机制不属于本 PRD,也不得把加载到的子 Session 自动插入普通 Rail 或 Activity View。
### 7.7 项目上下文
Activity View 跨项目扁平化。列表的展示单位始终是根 Session,不是项目卡片;不显示项目标题、不形成项目分组,也不提供项目折叠。
单条会话行严格复刻 Codex 的两层布局:
1. **第一行:会话标题 + 尾部状态栏**
- 会话标题是主信息;过长时按 Codex 当前行布局截断;
- 右侧尾部状态栏展示现有运行状态、未读提示和单条 hover actions,并在两行布局下与标题行对齐;
- Activity View 会话行不显示时间戳,也不在尾部重复显示 remote host environment icon。
2. **第二行:项目/source context**
- 不展示会话 prompt、Agent 回复或其他 transcript 内容,避免把会话正文混入工作队列;
- 有精确项目归属时,显示项目 icon + user project label;
- 没有精确项目上下文时,显示 source label fallback;
- Activity View 不为补齐第二行额外读取 Turn 或 transcript。
项目归属只能读取 canonical `railSectionKey` 和项目目录,不从 `cwd` 计算。点击会话行仍选择原 Session,不切换或重写其项目归属。
### 7.8 与现有搜索和导航的组合
| 现有能力 | 组合行为 |
| ------------------------- | ---------------------------------------------------------------------------------- |
| 搜索 | 沿用 Codex 当前搜索接管与恢复行为;不为 Tutti 另设搜索期间的 Priority 规则 |
| 项目折叠 | 仅属于普通视图;Activity View 不读取或修改项目折叠状态 |
| 分区显示条数 | 仅属于普通视图;Activity View 一次渲染已投影的全部区段成员,不出现“显示更多” |
| pin/unpin | 沿用现有 durable 行为;不创建 Activity View 专属 Pinned 区段,也不改变当前成员位置 |
| 标记未读 | 操作成功后 Session 进入 Priority 的 unread rank |
| rename/delete/open window | 沿用现有 Session action 与 interaction lock |
## 8. 状态与界面反馈
### 8.1 首次开启
开启视图后应在同一渲染帧内显示当前 Engine 已知结果,不清空列表、不切换为全屏 skeleton,也不显示 coverage 进度。开启动作不触发网络 loading。
### 8.2 Priority 空状态
Priority 区段始终存在。其成员为空时,在该区段内显示与 Codex `Nothing needs attention` 同构的单行弱化空状态;下方日期段仍正常展示。不替换整张 Rail,不增加说明卡片或额外 CTA。
### 8.3 数据错误
- Engine 当前没有会话:按正常空状态展示,不为 Activity View 创造第二套错误状态;
- Desktop daemon 推送失败或断连:沿用现有全局连接状态和恢复机制,不弹出 Activity View 专用错误;恢复后到达的 canonical updates 正常参与增量入队;
- 普通视图和 Activity View 共享 canonical entities,但各自 presentation state 不互相重置。
### 8.4 实时变化
- 开启时按当时 `waiting → unread → active` 生成 Priority 初始顺序;
- 开启期间,既有 Priority 成员即使 attention state 改变也不做全量移组或重排;行上的 spinner、unread、状态与可执行操作读取 live canonical facts;普通 Rail refresh 暂时不返回某个已入队 ID 时,也不得把它当成删除,继续使用该行最近一次已知 summary,直到下一次 toggle 重建 activation;
- Desktop daemon 新推送或更新的根 Session 先写入 Engine;满足 waiting/unread/active 的新候选再按当前 selector 顺序增量入队并按 exact ID 去重;
- activation 打开后由 daemon 新发现的普通 idle 根 Session 不进入 Priority,也不因为 canonical snapshot 到达而改变现有队列;打开时已存在的普通 idle Session 按时间进入日期段;
- 用户选择一个尚未出现在 canonical snapshot 中的历史 Session 时,详情层可以临时注入该 Session 的 summary;若它当前 idle,则按普通近期会话处理,不因选择动作进入 Priority,只有真实 waiting/unread/active facts 才可进入 Priority;
- 会话第一次进入本次 activation 的日期段时记录 recency,之后在本次视图中保持原日期段和相对顺序;已经进入 Priority 的会话不因已读或 idle 状态变化移入日期段;
- Session 被现有删除能力删除后,Activity View 按 canonical tombstone 立即移除对应行;pin/unpin 不触发 Activity View 专属区段变化;
- Activity View 本身不提供批量删除或归档入口;现有单行删除行为不在本 PRD 中修改;
- 关闭再开启、切换 sidebar context 或 activation 重建时,才基于最新 canonical facts 重新生成完整成员与顺序;
- 任何成员变化都不得改变当前选中的 Session,也不得强制 detail 重新 hydration。
### 8.5 当前选中会话
Codex 使用两层机制避免当前会话突然消失:
1. 已经位于本次 Priority snapshot 的条目在标记已读后仍保留原成员位置;不会在当前 activation 中立即移入日期段;
2. 新增的普通 idle canonical Session 不因晚到 snapshot 而进入 Priority;它只在下一次 toggle 的初始快照中按日期规则处理;
3. 关闭再开启时只按当时最新 facts 重建,不继承上一次 activation 的 Priority 成员;
4. 无论左栏是否仍显示,detail 保持当前 Session,不自动选择另一行;
5. hover 和 keyboard focus 不产生另一套冻结规则;稳定性来自 activation snapshot 与 controller-owned row cache。
该规则同时适用于所有 provider:Activity View 不创建 retained-idle admission 类型,也不把详情选择或已读后的 recency 变化解释为新的 activity。
## 9. 内存投影与实时入队
### 9.1 数据边界
Codex 的本地 Activity View 是当前 store 上的客户端 presentation policy,不是一个带 priority sort 参数的新服务端列表。Tutti 采用同样的简单边界:
- 输入是 Engine 当前内存态中可枚举的 canonical 根 Session,不是 React 当前挂载的 DOM 行;
- 开启视图不调用 `listSessionsPage`、不展开项目分区、不扫描 SQLite、不读取 transcript;
- 当前未进入 Engine 的历史会话不会因为开启视图而出现;
- 普通 Rail 原有加载和分页继续独立工作,但 Activity View 自身没有“加载更多”;
- 开启前已经在进行中的普通 Rail 请求不被取消;其结果按原流程进入 Engine 后,可作为新的内存态成员增量入队;
- Desktop daemon 推送的新建或更新会话写入 Engine 后,可实时进入 Priority 或对应日期段。
这个覆盖范围是已批准的产品取舍:Activity View 用于关注当前进行中和当前已知的会话,不承担历史发现职责。
### 9.2 Activation state
```ts
interface ActivityViewActivation {
cutoffDayStartUnixMs: number;
referenceDayStartUnixMs: number;
priority: readonly ActivityMember[];
recent: readonly ActivityMember[];
}
```
- activation object identity 区分每次开启;关闭后到达的旧渲染结果不能恢复已清理的 snapshot;
- activation 绑定创建它的 Engine 实例;Engine 被替换、surface 卸载或视图关闭时立即取消订阅并清理;
- sidebar 的 workspace、用户、Rail filter 或 AgentGUI context 变化时重建 activation;当前选中会话的 provider/target 变化不重建 activation,因为 Activity 队列跨 provider 展示;这是组件内部的 stale-state fence,不是新增筛选器或 host API;
- Activity View state 只保存 activation、成员引用、Priority 顺序和日期段 recency snapshot,不把 Session 副本放进 activation 或分页 cursor;controller 可以在当前 activation 内短暂缓存已入队行的最近一次 summary,用于抵抗普通 Rail refresh 的临时缺席;该缓存随 toggle、context/Engine 重建或 canonical tombstone 清理;
- cutoff 固定取本次 activation 的本地日期;持续打开跨午夜不自行刷新日期标题,关闭再开启时才使用新的 cutoff。
### 9.3 投影与事件算法
```text
activate(activityView, engine)
1. snapshot in-memory canonical root sessions from Engine
2. derive Priority and in-memory date buckets
3. subscribe to Engine canonical session/attention/delete changes
onEngineSessionUpsert(session)
1. receive the canonical Engine update after daemon reconciliation
2. ignore child rows at the Rail projection boundary
3. update live facts for existing members
4. incrementally enqueue newly eligible root sessions by exact ID
deactivate(activityView)
1. clear activation snapshot and retained row cache
2. keep canonical Engine sessions unchanged
```
Desktop daemon push 可能重复、乱序或在 surface 切换后迟到,因此:
- Engine 继续负责 canonical upsert/reconciliation;Activity View projection 不直接消费 transport payload;
- 按 exact `agentSessionId` 去重;
- 只消费当前 activation 所绑定 Engine 的 subscription;旧 Engine 或旧 activation 的回调被丢弃;
- 删除 tombstone 立即移除对应成员;
- 既有成员只更新 live facts,不因推送全量重排;新候选按 Codex incremental-merge 规则入队。
## 10. 投影模型
建议在 `@tutti-os/agent-gui` 内定义纯投影,而不是在 JSX 中拼装:
```ts
type AgentConversationPriorityReason = "waiting" | "unread" | "active";
interface AgentConversationActivityViewModel {
sections: Array<{
id: "priority" | `date:${string}`;
items: AgentConversationRailSummary[];
}>;
}
```
投影要求:
- 输入只接受 canonical summaries、attention facts 和 ordered membership;
- 输出引用稳定:一个 Session 更新不得重建所有无关行;
- 去重使用 exact `agentSessionId`;
- 未知 enum 或缺失 identity 必须 fail closed;
- rank 是 presentation policy,不写回 Session;
- 子 Session 在 projection 入口被过滤;其 lifecycle 不进入根行状态,只有 pending Interaction 通过 canonical root-attention selector 进入 waiting 排序;
- projection 仅服务本 PRD 的 Desktop Rail;不在此处承诺其他终端复用。
## 11. 组件和架构落点
| 层 | 建议职责 | 明确不负责 |
| ---------------------------------- | ------------------------------------------------------------------------- | ------------------------------- |
| Desktop daemon / runtime adapter | 将新建、更新、删除 Session 事件规范化并写入 Engine | priority rank、Rail 区段 |
| `agent-activity-core` | canonical Session、unread/attention selectors、实时 upsert/reconciliation | Rail DOM、菜单、滚动位置 |
| Conversation Rail Query Controller | 保持普通 Rail 既有加载、分页和搜索;暴露当前 Engine 内存态 | 为 Activity View 额外查询历史 |
| `agent-gui` projection | activation snapshot、rank、去重、日期区段、结构共享 | 网络请求、消息历史加载 |
| Rail view | 切换入口、空状态、行与区段渲染、可访问交互 | 解析 completion key、重建状态机 |
| Desktop host adapter | 显式启用 capability,并接入既有 daemon → Engine 事件通道 | 复制 controller 或业务排序 |
| 外部 host adapter | 传入可选 capability;关闭或缺失时保持普通 Rail | 被要求实现 Activity View 能力 |
本功能不新增 OpenAPI endpoint、SQLite 查询或独立的 Activity View query controller。Activity activation state 挂在既有 Conversation Rail query controller 的生命周期上;普通 Rail 已经加载进 Engine 的会话与 Desktop daemon 后续实时推送,是 Activity View 投影的全部数据来源。
## 12. 功能需求清单
| ID | 需求 | 优先级 |
| ----- | ------------------------------------------------------------------------------------ | ------ |
| FR-01 | Rail 提供可发现、可访问的 Activity View 切换入口 | P0 |
| FR-02 | 首帧基于 Engine 已知会话立即投影 | P0 |
| FR-03 | 只展示根 Session;子 lifecycle 留在详情,子 pending Interaction 投影为根行 attention | P0 |
| FR-04 | waiting/unread/active 事实可投影 | P0 |
| FR-05 | 近期日期段只使用当前 Engine 内存态,不触发补页 | P0 |
| FR-06 | Priority 按 waiting/unread/active 和 recency 排序 | P0 |
| FR-07 | Priority 与近期日期段全局去重 | P0 |
| FR-08 | 不新增筛选入口、筛选条件、组合状态或通用 filter API | P0 |
| FR-09 | 搜索接管与恢复行为逐项沿用 Codex | P0 |
| FR-10 | daemon 推送先写入 Engine,再按 snapshot 规则增量入队/移除 | P0 |
| FR-11 | 开启 Activity View 不触发 Session 查询、补页或 transcript hydration | P0 |
| FR-12 | 普通视图状态、scroll 和分页不受破坏 | P0 |
| FR-13 | 复用现有 Rail scroll;不设独立 visible limit | P0 |
| FR-14 | 不提供 Activity view options 标题菜单 | P0 |
| FR-15 | 不提供批量已读、批量归档或批量删除 | P0 |
| FR-16 | 现有 pin/unpin、单条标记已读和单条删除能力保持原样 | P0 |
| FR-17 | 首期不展示 coachmark,也不保存引导 preference | P0 |
| FR-18 | 首期不提供前 9 个 Priority 会话快捷导航 | P0 |
| FR-19 | 会话行按 Codex 两层布局展示标题、项目上下文、状态及 secondary content | P0 |
| FR-20 | 外部 host 可通过默认关闭的 runtime capability 禁用全部能力 | P0 |
## 13. 非功能需求
### 13.1 性能
- 开启按钮到已知内容首帧目标:本地 P95 小于 100 ms;
- 不因一个 Session 的 token streaming 重建整个 Activity View list;
- 不在每个 token 或每个 message delta 上重新执行全 workspace 排序;只在影响 Session rank/sort identity 的 canonical projection 变化时更新;
- 开启视图不得增加 Session 列表、SQLite 或 transcript 请求;
- daemon 高频推送先由 Engine 合并,再触发受影响 Session 的投影更新。
- host 未显式启用 capability 时,Activity 专用 selector 返回共享空投影,不扫描 Engine 根 Session;Activity 行隐藏时间戳时不订阅分钟时钟。
### 13.2 一致性
- activation 所属 workspace/filter scope 与 runtime Engine identity 均需保持一致;
- Desktop daemon 事件必须先经过 Engine canonical reconciliation,Activity View 不直接保存 transport payload;
- 已选 Session 即使从 Priority 或日期段消失,也不能被自动 unactivate;
- Priority snapshot 的成员变化不得反向 prune Engine entities。
### 13.3 可访问性与本地化
- 切换按钮、状态原因、加载和错误均通过 i18n;
- 中文 UI 文案不以中文句号结尾;
- 所有状态不能只靠颜色区分;
- Priority 和日期区段使用语义 heading,行重排不得抢焦点;
- keyboard navigation、focus visible 和 screen reader 语义必须覆盖;
- `prefers-reduced-motion` 下不使用位移动画表达重排。
### 13.4 隐私与诊断
- telemetry 不记录标题、prompt、cwd、项目路径或 Session ID;
- 错误 UI 不暴露 provider stderr、账号信息或 transport details;
- diagnostics 可记录 Engine origin kind、daemon 增量入队结果和匿名数量。
## 14. 测量边界
首期不新增 Activity View 产品埋点,避免为单一展示模式扩展数据采集范围。正确性和性能通过匿名本地性能测量、请求计数断言、Engine 投影测试及 parity checklist 验证;不得记录标题、prompt、cwd、项目路径或 Session ID。后续如需评估使用效果,另行评审事件、字段、隐私边界和数据保留策略,不在本 PRD 中预埋未批准事件。
## 15. 验收标准
### 15.1 核心功能
1. 开启 Activity View 只投影 Engine 当前内存态;不调用 Session list/page、SQLite 或 transcript 接口。
2. Engine 已知、距离现在超过 7 天但为 waiting 的根 Session 仍出现在 Priority。
3. Engine 已知、距离现在超过 7 天但为 unread 的根 Session 仍出现在 Priority。
4. 根会话 canonical `needsUserAction=true` 映射为 waiting,包含后代 pending Interaction;根 Session 自身运行映射为 active;子 Session 的 lifecycle working/waiting 不改变根行 lifecycle 状态。
5. waiting 排在 unread 前,unread 排在 active 前;unread 不按结果成功/失败二次排序。
6. 同 rank 两条会话按 recency 倒序;时间相同保留输入顺序。
7. 同时满足 Priority 与近期日期范围的 Session 只出现在 Priority。
8. 近期会话直接按 Today、Yesterday 或 weekday 日期标题排列;不存在额外总标题或“显示最近会话”开关。
9. Priority 标题右侧不存在 Activity view options 或其他标题菜单。
10. 不显示 Pinned、Work/Chat source 和 Scheduled 选项,也不显示独立 Pinned 区段。
11. Activity View 不提供 Mark all as read、Archive chats、Delete conversations 或其他批量操作。
12. 现有 pin/unpin、单条标记已读和单条删除能力保持原样;pin/unpin 不改变成员位置,标记未读按 unread 规则进入 Priority。
13. 现有单条删除及其他页面已有的删除入口、确认和权限行为不因本功能改变;删除结果通过 canonical tombstone 从 Activity View 移除。
14. Activity View 不显示“加载更多”,所有当前内存态成员直接渲染。
15. activation 内既有 Priority 顺序不因状态变化全量重排;新候选增量合入,关闭再开启后按最新事实重建。
16. 日期段使用会话首次进入本次 activation 时的 recency,持续打开跨午夜不自动换组。
17. Activity View 不注册前 9 个 Priority 会话快捷导航,也不新增 Rail 级全局快捷键。
18. Activity View 的展示单位是根 Session;项目只作为行内上下文,不出现项目卡片、项目标题、项目分组或折叠控件。
19. 会话行第一行只显示主标题,右侧状态栏与标题行对齐;该行不显示时间戳,也不重复显示 remote host environment icon。
20. 会话行第二行只显示项目/source context;不得为补齐该内容新增 Turn 或 transcript 请求。
21. 第二行不展示会话 prompt 或 Agent 回复;有精确项目归属时显示项目 icon 和 user project label,没有精确项目归属时显示 source fallback。
22. 标题溢出、secondary content 截断、状态图标、未读提示及 hover actions 的可观察布局与目标 Codex build 一致。
### 15.2 Engine 隔离、搜索与导航
1. Engine 实例替换或 surface 卸载时清理旧 activation;旧 Engine 的后续更新不能污染新列表。
2. Activity View 不要求 host 提供额外 context/scope identity API;内部仅复用页面已有 workspace、用户和 Agent context 作为 activation stale-state fence。页面不存在本功能新增的 target/workspace/provider 筛选入口或组合状态。
3. 搜索开始、结果展示和清空恢复均与 Codex 当前行为一致,不增加独立 Priority 搜索状态。
4. 打开 Priority 中的 Session 沿用现有 selection/detail hydration;选择一个历史 Session 只允许产生详情层 transient summary,不得因此改变 Activity View 的 Priority 顺序;其他行不因 Activity View 触发后台 hydration。
5. 当前 Agent Session 被标记已读后在本次 snapshot 中保留原位置;退出并重新开启时才按最新 facts 重建,detail 始终保持打开且不自动选中其他会话。
6. 项目折叠和显示条数不被修改;滚动复用现有 Rail mode 状态,不创建 Activity View 专用 scroll key。
### 15.3 实时与异常
1. 根或子 Session 的新 pending Interaction 到达时,根会话以 waiting 进入 Priority 且不需要手动刷新;子 Session 的 lifecycle 状态仍不覆盖根行状态。
2. Interaction 处理完后行状态立即更新,但本次 snapshot 中的既有成员与顺序保持;重新开启后按剩余 facts 重建。
3. Turn 完成或失败生成新 unread completion 时,新候选增量进入 Priority;既有成员不因该事件全量重排。
4. Desktop daemon 推送的新建/更新根 Session 先进入 Engine;符合规则时无需刷新即可增量入队,重复事件不产生重复行。
5. 标记已读、标记未读和 delete 按 snapshot/watcher 规则更新;pin/unpin 不改变 Activity View 成员或顺序;任何操作均不触发未经定义的全量重排。
6. daemon 断连沿用现有全局反馈,Activity View 不增加 partial badge 或阻断式错误页。
7. surface detach、关闭 Activity View、Engine 替换或 activation generation 变化后,旧订阅不得恢复已清理 snapshot。
8. host capability 为 `false` 或缺失时,入口不注册,不创建 activation,也不触发 Activity View 查询副作用。
9. capability 在视图开启期间变为 `false` 时恢复普通 Rail;Session、pin 和 read/unread durable state 不变。
### 15.4 性能与结构稳定性
1. 一个无关 Session 的消息流更新不改变其他 priority row 的对象引用。
2. 一次 Activity View activation 不产生任何新增网络或 SQLite 查询。
3. 未选中的 Session 只读取 Engine snapshot 中已有的消息缓存,不发起消息历史或 transcript 请求。
4. daemon 批量或重复推送不会形成重复行或无界全列表重排。
5. `pnpm check:agent-gui-degradation` 预算不增加。
## 16. 测试策略
### 16.1 单元测试
- rank 判定矩阵;
- 根会话 canonical `needsUserAction` 到 waiting、根 Session 普通运行到 active 的映射;
- waiting/unread/active 的排序,以及已读后既有 Priority 成员的 snapshot retention;
- child Session 过滤、child lifecycle 不改变 root Rail status,以及 child pending Interaction 保留 root attention;
- Priority 与日期段去重;
- 7 日 cutoff 和本地午夜边界;
- imported、hidden、deleted、unknown enum;
- projection structural sharing;
- completion key 到 exact Session target 的 activity-core helper;
- host runtime capability 的启用矩阵,缺失 capability 按 `false` 处理。
### 16.2 Controller 测试
- activation 只读取 Engine 当前内存态,不调用 Session list/page runtime;
- Desktop daemon 新建、更新、删除事件先经 Engine 后增量更新成员;
- 重复和乱序事件按 exact Session ID 与 canonical revision 收敛;
- detach、Engine replacement、search activation 和 stale generation;
- capability 关闭或缺失时不创建 Activity View activation。
### 16.3 Engine 与 adapter 测试
- Desktop daemon DTO mapper 不丢 latest Turn、pending Interaction、parent/root 或 rail identity;
- daemon 推送的 child Session 不形成独立 Rail 行;child lifecycle 不覆盖根 status,pending Interaction 只更新根 attention;
- 删除 tombstone 从 Engine 和 Priority 投影移除 exact Session;
- 不新增 OpenAPI、SQLite query 或 generated client 变更。
### 16.4 UI 与端到端
- 与目标 Codex build 并排核对入口位置、icon、ghost/accent-subtle 状态、tooltip、`aria-pressed` 和 accessible label;
- 核对首期不展示 coachmark,也不创建对应 preference;
- 核对 Activity View 不注册 Rail 级会话快捷键,现有会话内部快捷键不受影响;
- 核对 Priority 标题右侧不存在 options 菜单或其他标题操作;
- 核对单条会话行第一行只显示主标题、标题行对齐的尾部状态栏,以及隐藏时间戳和重复 remote host environment icon;
- 核对第二行不显示会话 prompt 或 Agent 回复,并显示项目/source context;
- 核对标题溢出与 secondary content 的布局,不因补齐 secondary content 产生 Turn/transcript 请求;
- Codex 同构的 Priority empty;开启动作没有额外 loading;
- 搜索临时接管和恢复;
- 普通视图与 Activity View 复用同一 Rail mode scroll state;
- 实时 pending → answered → completed → read 全链路;
- Desktop daemon 推送当前未在 Engine 中的新会话后,符合条件的根 Session 实时入队;
- 已读或变为 idle 的既有 Priority Session 在当前 snapshot 保持原位置;重建时不继承旧 Priority,普通 idle 按最新日期规则处理;
- Priority 空但日期段非空时只在 Priority 区段显示 `Nothing needs attention` 同构空状态;
- Workbench AgentGUI 与独立 Agent window;
- 外部 host 在 capability 为 `false` 和缺失两种情况下均保持原普通 Rail,且没有 Activity View 查询副作用;
- 使用真实 Desktop daemon 事件验证跨项目会话增量入队,并断言开启动作没有新增 list/page/transcript 请求。
### 16.5 Codex parity gate
每次实现或调整本功能时记录目标 Codex.app 版本,并维护一份逐项 parity checklist。以下任一可观察行为不同,且未列入 4.1 差异表并获批,均视为验收失败:
- 控件位置、层级、文案语义、默认值或持久化时机;
- 区段成员、排序、日期标题、空状态或 secondary content;
- 现有单条标记已读、单条删除、置顶、搜索和切换模式后的结果;
- 当前选择、焦点、滚动、activation 内稳定顺序、增量成员变化或加载反馈;
- keyboard、screen reader 与 reduced-motion 行为。
若 Codex 版本升级改变该功能,先更新参考事实和 parity fixtures,再决定 Tutti 是否同步;不得在实现代码中静默漂移。
## 17. 发布策略
### Phase 0:契约和测量
- 固化 optional boolean runtime capability,缺失时默认关闭;
- 建立投影性能与“activation 新增请求数为 0”的基线;
- 固化 optional boolean runtime capability 契约;该契约不要求新增 OpenAPI endpoint。
### Phase 1:Desktop 内测
- shared projection 与 Desktop host adapter 落地;
- Desktop Workbench 和独立 Agent window 显式启用 runtime capability;
- Workbench 和独立 Agent window 同时开放;
- 记录内存态漏项、daemon 增量入队和性能指标;
- 外部 host capability 关闭或缺失时 fail closed,并回归普通 Rail。
### Phase 2:默认开放
- 性能和漏项验收通过后默认启用入口,但视图仍默认关闭;
- 外部 host 继续通过 runtime capability 独立控制是否启用;
- 更新 `docs/architecture/agent-gui-node.md` 为当前事实。
## 18. 风险与缓解
| 风险 | 后果 | 缓解 |
| ---------------------------- | ----------------------------- | ---------------------------------------------------------------------------------- |
| 只读取当前 DOM 行 | 漏掉已在 Engine、但未挂载的行 | projection 输入取 Engine 内存态,不读取 DOM |
| 用户误以为覆盖全部历史 | 未加载旧会话不会出现 | 产品定位为当前工作队列;不开启历史查询,不展示 coverage 承诺 |
| daemon 推送重复或乱序 | 重复行或状态回退 | Engine canonical reconciliation、exact ID 去重、activation fence |
| 子 Session 与根 Session 并列 | 左栏重复、状态难理解 | Rail 过滤 child;根 status 只读根 lifecycle,根 attention 汇总 pending Interaction |
| 实时更新造成列表移动 | 用户需重新定位 | 复刻 Codex activation snapshot;既有成员不全量重排 |
| 批量执行 Tutti 删除 | 产生不可恢复的高风险批量操作 | Archive chats 虽映射为删除,首期仍不提供 archive/delete 批量入口 |
| 多 surface 状态互相影响 | 一个窗口切换另一个窗口 | activation 保持 surface-local |
| 共享包升级影响外部 host | 意外出现入口或触发查询 | optional capability 默认关闭;disabled-path consumer tests |
## 19. 已确认的范围边界
1. Activity View 的单项是根 Session,不是项目卡片;第一行只显示主标题,第二行只显示项目/source context,不展示会话 prompt 或 Agent 回复,且不额外读取 Turn。
2. Tutti 没有 canonical scheduled Session,首期不显示 Scheduled。
3. Activity View 只投影 Engine 当前内存态,不补近期日期段分页或历史 attention 会话。
4. 首期不提供 Activity view options 标题菜单,因此不实现 Pinned、Mark all as read、Restore defaults 和 Archive chats。
5. 首期不展示 coachmark,也不保存“已看过引导”状态。
6. 首期不提供前 9 个 Priority 会话快捷导航,也不新增 Rail 级全局快捷键。
7. Activity View 不提供任何批量操作;现有 pin/unpin、单条标记已读和单条删除能力保持原样。
8. Mobile 不在范围;外部 host 通过默认关闭的 capability 禁用本功能。
## 20. 参考与现状依据
### 20.1 Codex.app 参考结论
对当前安装版 Codex.app 的验证显示:
- Activity View 首先同步使用当前前端 catalog/store;
- Codex 本地任务模式不会因为开启 Activity View 再调用 `thread/list` 补 Priority;Tutti 只参考该本地模式,不实现 ChatGPT 近期会话补页分支;
- 普通侧边栏的无限滚动是独立逻辑,约每批 10 条;
- Activity View 本身不提供“加载更多”;开启时快照 Priority 顺序与日期段 recency,期间保留既有顺序并增量合入新候选;
- Codex 某些本地任务分支会保存 exact recency,并允许 idle 行留在 Priority;Tutti 本实现不采用这个独立 admission marker,只保留当前 activation 中已入队成员直到下一次 toggle;
- 已进入当前 Priority snapshot 的会话在标记已读后仍保持原位置;下一次重建时普通 idle 按最新事实进入对应日期段或被 cutoff 排除;
- 子线程通过 `parentThreadId`/sub-agent source 建模;左栏过滤子线程,父线程承担导航表示;
- Priority 区段不会加载完整 transcript;Tutti 进一步约束为不因该视图执行任何 Turn hydration;
- Activity View 本地任务行使用两层布局:第一行只显示标题和标题行对齐的尾部状态栏,不显示时间戳;第二行只显示项目/source 信息,不展示会话 prompt 或 Agent 回复;
- 公开的 `thread/list` 是 cursor 分页接口,优先级本身是客户端 presentation policy,而非公开的服务端排序参数。
公开接口参考:[Codex App Server](https://learn.chatgpt.com/docs/app-server)
### 20.2 Tutti 当前能力
- Conversation Rail 已有普通列表加载和分页;本功能不调用或修改这些接口;
- 当前 section page size 与 visible increment 为 5,但 Engine 内存态可能包含更多已加载或实时推送的 Session;
- Session summary 已包含 status、`needsUserAction`、sort time、unread completion 和 exact rail section identity;
- Desktop daemon 的 Session push 已进入 Engine canonical reconciliation;本功能在该层之后订阅变化;
- Message Center 已存在 priority/status/agent/time 分组,可复用其产品语义,但不能成为 Rail 的第二数据源;
- attention/read state 已在 Engine 中按用户与 workspace 分区,并由 Desktop local storage 持久化 completion keys。
这些基础能力使首期重点集中在“内存态 Codex 同构 Activity View presentation + daemon 实时入队 + 根 lifecycle/会话树 attention 分离投影”,无需新增 Activity View API、数据库查询或 Session lifecycle。
@@ -0,0 +1,773 @@
# Connector 原生 MCP、CLI 与 Skill 接入方案
Status: P0 implemented
## 1. 决策摘要
Connector Runtime 不再把 MCP、CLI 和 Skill 包装成 Tutti 私有的通用执行协议。
连接器只负责安装、鉴权、生命周期、运行时隔离和能力发现;Agent 按能力类型使用
原生机制:
- MCP 注册到 Agent 的本地 MCP client,通过标准 `tools/list` 和
`tools/call` 使用。
- CLI 以连接器专属的稳定命令发布到 Agent 的 `PATH`,通过 shell 正常执行。
- Skill 通过 Agent 原生 Skill roots、scanner 和 reader 发现与读取。
公开的 Connector 发现命令最终只保留:
```bash
tutti connector available
```
下线以下四个公开命令:
```bash
tutti connector capabilities
tutti connector skills
tutti connector skill read
tutti connector invoke
```
首期不提供用户级 Connector 启停能力:
```bash
tutti connector enable --connector github
tutti connector disable --connector github
```
连接器是否注册由现有安装和鉴权状态决定:
- 已安装且当前账户鉴权就绪:注册运行时能力。
- 卸载、断开连接或鉴权失效:移除运行时能力。
## 2. 背景与根因
当前 ImplementationHost 已经具备两类上游 MCP client:
- managed stdio MCP client;
- remote Streamable HTTP MCP client。
两类 client 都会完成 `initialize`、`notifications/initialized` 和分页
`tools/list`。问题不在上游连接,而在上游工具注册之后的执行模型。
当前链路把每个 MCP tool 转换为 `connectorCommand`,注册进通用
`CommandRegistry`:
```text
上游 MCP
-> 本地 MCP client
-> connectorCommand
-> connector capabilities
-> connector invoke
```
这使标准 MCP 被降级成 Tutti 私有 RPC,带来以下问题:
- Agent 无法通过自己的 MCP client 感知连接器工具;
- MCP 的工具发现、schema、调用结果和动态列表通知被重复封装;
- CLI 也必须绕过正常 shell,统一提交 JSON 给 `connector invoke`;
- Skill 必须通过 `connector skill read` 兼容读取;
- Prompt 被迫指导 Agent 执行一套 Tutti 专属的发现和调用流程;
- `connector capabilities`、`connector skills` 与 MCP、Skill 原生发现能力重复。
第一个错误状态出现在 `registerMCPTools`:native MCP tool 在这里进入了
`CommandRegistry`,而不是进入 MCP Registry。修复必须从注册边界开始,不能只删除
CLI 命令或修改 Prompt。
### 2.1 WorkBuddy 对照结论
本机 WorkBuddy 的连接器 MCP 配置位于独立的
`~/.workbuddy-ai/connectors/default/mcp.json`,其中以 `mcpServers` 管理
`connector:<id>` 命名的连接器 Server;用户自定义 MCP 则位于根级 `mcp.json` /
`.mcp.json`。可复用的关键点不是文件格式本身,而是两条边界:
- Connector MCP 作为 MCP client 的原生 Server 配置进入 Agent,而不是转成私有
invoke command;
- Connector-owned MCP 配置与用户自定义 MCP 分开存放、分开管理。
Tutti 沿用这两个边界,但结合现有 daemon Reconcile 和动态 route 生命周期,将所有
Connector-owned MCP 投影为一个本地 Server `connector`。这样 Agent 的 MCP 配置
稳定不变,安装、鉴权、升级和卸载只改变该 Server 的 `tools/list`;用户自定义 MCP
仍保留在各 Provider 原有配置中。
## 3. 目标和非目标
### 3.1 目标
- Agent 把 Connector MCP 当作普通 MCP Server 使用。
- 一个已运行的 Agent Session 能感知 Connector MCP 工具的动态增删。
- CLI 通过连接器专属命令直接执行,保持标准 argv、stdin、stdout、stderr 和退出码。
- Connector Skill 通过 Provider 原生 Skill 机制读取。
- 用户自定义 MCP 和 Connector MCP 完全隔离。
- 对外只保留一个无执行能力、无秘密信息的 Connector 发现命令。
- 延用现有 release、generation、鉴权和 Reconcile 生命周期,不重新设计安装体系。
### 3.2 非目标
- 首期不增加 `enable`、`disable`、`enabled` 持久化字段、API 或 UI。
- 首期不把用户自定义 MCP 聚合进 Connector MCP Server。
- 首期不扩展 MCP resources 和 prompts;先完成 tools 的原生接入。
- 不保留改名后的通用 invoke 命令。
- 不修改远端 Connector Market 的发布和签名协议。
## 4. 目标架构
```text
Connector Manifest / Installed Release
|
v
ImplementationHost
| |
| +-------------------------------+
v v
Upstream MCP Client Connector CLI Entrypoint
| |
v v
ConnectorMCPRegistry stable connector-specific shim
| in Tutti state bin directory
v |
local Streamable HTTP MCP Server v
name = connector Agent shell / PATH
|
v
Agent native MCP client
Connector Skill directories
|
v
Agent runtime extra Skill roots
|
v
native Skill scanner / reader
```
正确的 MCP 执行链路为:
```text
上游 MCP
-> Tutti 管理的上游 MCP client
-> ConnectorMCPRegistry
-> 本地 MCP Server "connector"
-> Agent 原生 MCP client
-> tools/list / tools/call
```
Connector Runtime 不再把 MCP tool 投影为 CLI capability。
## 5. Connector MCP Server
### 5.1 服务边界
公共 `packages/connector/runtime/mcpserver` 提供本地 Streamable HTTP MCP
Server;`tuttid` 在组合根中启动一个 daemon 生命周期实例:
```text
server name: connector
listen: 127.0.0.1:<dynamic-port>
endpoint: /mcp/connector
```
该 Server 只投影 Connector Market 管理、当前运行状态有效的 MCP route。
用户通过 Provider 配置的自定义 MCP 不进入此 Server,也不进入
`ConnectorMCPRegistry`。
Server 即使暂时没有工具也保持运行并注入 Agent。连接器发生变化时只更新
`tools/list`,不修改 Agent 的 MCP Server 配置。
### 5.2 首期协议范围
本地 Server 同时提供两种 Agent-facing HTTP MCP 表面,并共享同一个
`ConnectorMCPRegistry`:
- Tutti modern client 使用 MCP `2026-07-28` 的无状态请求模型。每次调用都是一个
独立的 `POST`,请求参数的 `_meta` 携带 protocol version、client info 和 client
capabilities;HTTP header 同时携带 `MCP-Protocol-Version`、`Mcp-Method`,工具调用
还校验 `Mcp-Name` 和 schema 声明的 `Mcp-Param-*`。
- Codex、Claude 和其他 Provider 原生 client 使用 MCP `2025-06-18` 的标准
`initialize -> notifications/initialized -> tools/list -> tools/call` 链路。该表面还
返回空的 `resources/list` 和 `resources/templates/list`,因为首期只投影 tools。
modern 表面实现:
- `server/discover`;
- `tools/list`;
- `tools/call`;
- `subscriptions/listen`,并通过 SSE 发送
`notifications/subscriptions/acknowledged` 和
`notifications/tools/list_changed`。
Provider 原生表面实现:
- `initialize`;
- `notifications/initialized`、`notifications/cancelled`;
- `ping`;
- `tools/list`、`tools/call`;
- `resources/list`、`resources/templates/list`,首期固定返回空列表。
两种表面都只接受 `POST`,不会在 daemon 内维护协议 Session。HTTP `GET` / `DELETE`
和 `MCP-Session-Id` 不属于首期范围;长连接只用于 modern
`subscriptions/listen` 的通知流。Provider 原生表面声明 `tools.listChanged=false`,
因此不会承诺尚未实现的原生 list-changed 通知。
上游 tool 的 `name`、`description`、`inputSchema` 和调用结果保持 MCP 语义,
只在本地聚合层增加连接器命名空间。
### 5.3 工具命名
本地工具名使用:
```text
<connectorKey>_<upstreamToolName>
```
例如:
```text
github_search_issues
notion_query_database
```
Agent 侧最终看到的完整工具标识由 Provider 决定,典型形式为:
```text
mcp__connector__github_search_issues
```
Registry 在提交前必须校验:
- connector key 和上游 tool name 均满足 canonical name 规则;
- 加前缀后不超过 MCP client 的工具名长度限制;
- 同一 generation 内无重名;
- input schema 是合法 JSON Schema 对象;
- 本地名可以无歧义地映射回 connector key 和上游原始 tool name。
不能通过静默覆盖处理冲突。冲突应使新 route 注册失败,并保留旧 generation。
## 6. ConnectorMCPRegistry
### 6.1 职责
新增独立的 `ConnectorMCPRegistry`,并将原 `CommandRegistry` 收敛为只保存
discover metadata 的 `RouteRegistry`。MCP Registry 保存经过验证的不可变 route
binding,至少包含:
```go
type ConnectorMCPRoute struct {
ConnectorKey string
Generation uint64
Client MCPClient
Tools []ConnectorMCPTool
}
type ConnectorMCPTool struct {
LocalName string
UpstreamName string
Description string
InputSchema map[string]any
}
```
Registry 提供:
- 按 generation 原子提交一个 Connector 的完整工具集合;
- 按 connector key 和 generation fence 移除 route;
- 生成稳定、排序后的 `tools/list` 快照;
- 将 `tools/call` 解析到不可变 route binding;
- 在列表发生实际变化后发布 list-changed 事件。
### 6.2 动态注册
注册流程:
```text
安装完成或鉴权就绪
-> Reconcile 启动上游 MCP client
-> initialize
-> notifications/initialized
-> 分页 tools/list
-> 校验并构建完整 route
-> 原子 Commit 新 generation
-> 广播 notifications/tools/list_changed
```
更新流程:
```text
启动新 release
-> 新 client 完成 initialize 和 tools/list
-> 原子替换旧 generation
-> 广播 tools/list_changed
-> 关闭旧 client
```
新 route 未就绪前旧 route 始终可用,不允许先删后加造成工具空窗。
移除流程:
```text
卸载、断开连接或鉴权失效
-> fence 当前 generation
-> 从 Registry 移除 route
-> 广播 tools/list_changed
-> 关闭上游 client
```
`tools/call` 开始时解析不可变 binding,并在调用前验证 generation。命中已经
fence 的 route 时返回标准 MCP 错误,不回退到私有 CLI 调用。
现有 RouteTable 已有 generation-fenced Commit 和 Remove 语义,应复用其状态
模型,避免再建立一套独立生命周期状态机。
## 7. Agent MCP 感知
### 7.1 类型化运行时配置
当前 `PreparedRuntime` 只有 `Cwd` 和 `Env`,不能表达 MCP Server。新增统一的
类型化描述:
```go
type MCPServerBinding struct {
Name string
Type string
URL string
Headers map[string]string
}
```
传递链路为:
```text
runtimeprep.PreparedRuntime
-> agenthost.PreparedRuntime
-> agentruntime.Session
-> Provider adapter
```
不使用环境变量承载非类型化 MCP JSON,也不要求 Agent 自己执行
`connector available` 后再完成注册。
daemon 组合层在构造 Agent Service 和 Host 之前创建一个稳定的
`ConnectorRuntime` 端口。端口背后的 Registry 可以随 Reconcile 动态变化,但
Agent 恢复流程始终持有同一个可用依赖,避免启动恢复期间因晚注入而漏掉 Connector
MCP。`DefaultPreparer` 的最终输出以其 `PreparedRuntime.MCPServers` 为唯一事实,不能
在 Provider-specific prepare 之后丢弃或另建第二份结果字段。
### 7.2 ACP Provider
在 `session/new`、`session/load` 和需要的 resume 路径传入:
```json
{
"mcpServers": [
{
"name": "connector",
"type": "http",
"url": "http://127.0.0.1:<port>/mcp/connector",
"headers": [
{
"name": "Authorization",
"value": "Bearer <session-token>"
}
]
}
]
}
```
创建 Connector Session Binding 前,必须先检查 initialize 响应中的
`agentCapabilities.mcpCapabilities.http == true`。只有明确声明支持的 Provider 才注入
Connector MCP、routing hints、Skill 和 Connector 提示;字段缺失、值为 false、能力探测
失败或 Connector 自身失败时,整体降级为不含 Connector 的普通 Session,不得阻断启动。
### 7.3 Codex
在 Session 隔离的 Codex 配置中注入:
```toml
[mcp_servers.connector]
url = "http://127.0.0.1:<port>/mcp/connector"
http_headers = { Authorization = "Bearer <session-token>" }
```
### 7.4 Claude SDK
在 Session 启动和恢复参数的 `mcpServers` 中注入相同 binding,不通过 Prompt
模拟工具可用性。
### 7.5 Tutti Agent
Tutti Agent 与 Codex 一样,在 Session 隔离配置中写入
`[mcp_servers.connector]`。所有 Provider 的投影都必须消费同一份
`PreparedRuntime.MCPServers`;不允许 Codex 正常、ACP/Claude/Tutti Agent 因各自
旁路而出现行为不一致。
### 7.6 动态列表刷新
已运行 Session 通过一个 `subscriptions/listen` SSE 连接订阅
`notifications/tools/list_changed`,收到 acknowledge 后才认为订阅建立。
Provider 兼容顺序为:
1. 标准 MCP list-changed 通知;
2. Provider 原生 MCP reload;
3. 下一轮开始前重新连接本地 `connector` Server;
4. Provider 确实不支持刷新时,才要求 resume 或重启 Session。
Provider 的兼容降级只能影响刷新时机,不能重新引入 `connector invoke`。
## 8. 会话安全与授权范围
本地 MCP Server 必须:
- 只监听 loopback;
- 校验 Host 和 Origin;
- 使用每个 Agent Session 独立的随机 bearer token;
- token 绑定 workspace ID 和 Agent Session ID;
- 相同 Session 重新签发时立即撤销旧 token;Session 清理、账户退出和 daemon 关闭时
分别执行 `RevokeSession` 或 `RevokeAll`;
- 不在日志、Prompt、CLI 输出或 `connector available` 中泄露 token。
这里的 bearer token 是 daemon 签发的本地连接能力和生命周期隔离手段,不是
Connector 级权限模型。Server 向当前账户的 Agent Session 投影所有已发布且运行有效的
Connector route,不存在 `allowedKeys`、`selectedConnectorKeys` 或每连接器授权 Grant。
token 不依赖固定 TTL;失效由 Session、账户和 daemon 生命周期显式驱动,避免运行时间
超过固定时长后 MCP 无故消失。
## 9. 唯一公开命令:`connector available`
### 9.1 职责
`connector available` 只负责发现:
- 当前有哪些可用连接器;
- 连接器的名称和用途;
- 连接器提供哪些原生接口;
- MCP 工具使用哪个本地 Server 和命名前缀;
- CLI 应执行哪个稳定命令;
- 有哪些 Connector-owned Skills。
它不负责:
- 返回 MCP 工具 schema;
- 读取 Skill 内容;
- 执行 MCP 或 CLI;
- 注册 MCP Server;
- 返回本地端口、完整 URL、bearer token、headers、OAuth token;
- 返回上游 MCP endpoint 或内容寻址安装路径。
### 9.2 返回协议
建议扩展 `ConnectorSummary`:
```json
{
"key": "github",
"name": "GitHub",
"description": "GitHub repository and issue operations",
"skills": [
{
"name": "github-workflow",
"title": "GitHub Workflow",
"description": "Work with repositories, issues and pull requests"
}
],
"interfaces": [
{
"kind": "mcp",
"serverName": "connector",
"toolPrefix": "github_",
"status": "ready"
},
{
"kind": "cli",
"command": "tutti-connector-github",
"status": "ready"
}
]
}
```
规则:
- 一个连接器可以同时声明 MCP、CLI 和 Skill。
- MCP 的完整动态工具 schema 只以 `tools/list` 为事实来源。
- CLI 只暴露稳定的连接器专属命令,不暴露实际 release 路径。
- Skill 摘要不暴露读取接口;Agent 通过已注入的原生 Skill root 解析内容。
- `status` 描述运行条件,不表示用户 enable/disable 状态。
### 9.3 为什么删除其他发现命令
`connector capabilities` 当前仅用于在调用 `connector invoke` 前取得 capability
ID 和 input schema。原生 MCP 使用 `tools/list`,CLI 使用真实命令,因此该命令
没有独立职责。
`connector skills` 返回的摘要已经包含在 `connector available` 中,Skill 内容
又由原生 Skill scanner/reader 管理,因此该命令是重复入口。
## 10. CLI 原生执行
每个包含 CLI interface 的连接器在 Tutti state 的 `bin` 目录发布稳定的专属
命令:
```text
tutti-connector-<connectorKey>
```
示例:
```bash
tutti-connector-github issue list --repo tutti-os/tutti
```
现有 Agent runtime 已把该 `bin` 目录加入 `PATH`。shim 负责:
- 固定指向当前已验证 release 的 CLI entrypoint;
- 随 route generation 原子发布,并只由相同 generation 内容移除;
- 设置连接器运行所需且允许暴露给子进程的环境;
- 原样转发 argv、stdin、stdout、stderr、信号和退出码。
Skill 和 `connector available.interfaces[].command` 只引用稳定命令,不引用
内容寻址 release 路径。
当前只接受 JSON stdin 的 typed command 不属于正常 CLI。对应连接器必须在切换前
完成以下一种迁移:
- 发布真正的 CLI entrypoint;或
- 在连接器专属 shim 中定义稳定、可文档化的子命令和参数映射。
不能用另一个全局 generic command 继续承载 capability ID 和 JSON input。
## 11. Skill 原生读取
延用现有 Connector routing hints 和 Tutti Agent extra Skill roots:
```text
active Connector route
-> ConnectorRoutingHint.SkillRoot
-> runtimeprep extra Skill roots
-> Provider native Skill scanner
-> Provider native Skill reader
```
需要更新 Connector policy 和运行时 Prompt:
- 删除 `connector skills`、`connector skill read`、`connector capabilities`、
`connector invoke` 的指导;
- 对 MCP interface,指导 Agent 使用已注册的 `connector` MCP Server 工具;
- 对 CLI interface,指导 Agent 使用 `available` 中的稳定 CLI command;
- 保留“只能使用所选 Connector-owned Skill”的作用域约束;
- 不把 Skill 文件内容复制进 CLI JSON 响应。
连接器运行中移除后,MCP `tools/list` 是实时权威状态。已经被 Provider 读取进当前
上下文的 Skill 文本无法从历史上下文撤回,Agent 在实际调用前仍必须以当前 MCP
或 CLI route 可用性为准。
## 12. 命令下线
目标公开命令集:
```text
connector.available
```
从 CLI capability registry、Prompt policy、runtime capability allowlist、帮助文本、
测试 fixture 和架构文档中移除:
```text
connector.capabilities
connector.skills
connector.skill.read
connector.invoke
```
根本方案完成后不保留公开兼容别名。这四个命令应返回标准 command-not-found。
内部通用 CLI capability/invoke 执行链和 Connector Broker 的 `Capabilities`、
`Skills`、`ReadSkill`、`Invoke` 方法一并删除;诊断能力通过日志、指标、MCP
Registry debug snapshot 或内部 daemon 调试接口实现,不重新暴露为 Agent 公共命令。
## 13. 实施顺序
### 13.1 拆分 MCP 注册模型
涉及:
- `packages/connector/runtime/implementationhost/host.go`
- `packages/connector/runtime/implementationhost/mcp_routes.go`
- `packages/connector/runtime/implementationhost/broker.go`
- `packages/connector/runtime/route_table.go`
工作:
- 新增 `ConnectorMCPRegistry`;
- `registerMCPTools` 改为提交 MCP route;
- `RouteRegistry` 只保存 discover metadata,不再承载 MCP/CLI invoke;
- 保持现有 Reconcile 和 generation fencing;
- 为 Registry 增加 list-changed 订阅。
### 13.2 实现本地 MCP Server
在 `packages/connector/runtime/mcpserver` 中实现公共 Server;`tuttid` 的
`connectormcp` service 只保留类型别名和启动转发,并在 wiring 中负责实例生命周期。
实现现代无状态 HTTP discovery、工具列表、调用路由、订阅通知和 Session token;
不实现 Connector allowlist 或协议 Session。
### 13.3 注入 Agent Runtime
涉及:
- `packages/agent/runtimeprep/types.go`
- Agent host runtime preparation;
- `packages/agent/daemon/runtime/types.go`
- ACP、Codex app-server、Claude SDK 的 Session 创建和恢复路径。
工作:
- 新增并贯通 `MCPServerBinding`;
- 为每个 Agent Session 签发本地 connector token;
- 将 `connector` Server 注入所有支持 MCP 的 Provider;
- 实现各 Provider 的动态列表刷新兼容策略。
### 13.4 发布 CLI shim 和 Skill roots
- 为 CLI interface 创建、更新和清理稳定 shim;
- 确认 state `bin` PATH 在所有 Provider 中一致;
- 保持 Skill root 注入,并移除兼容读取路径;
- 更新连接器包的 Skill 内容,使其使用原生 MCP 和 CLI。
### 13.5 收敛发现协议和删除命令
- 扩展 `connector available` 的 `interfaces`;
- 删除四个公共 command capability 和 handler 分支;
- 删除 ImplementationHost 中失去调用方的通用 Broker 方法;
- 更新 Connector policy、运行时 Prompt、测试 helper 和文档;
- 更新 `docs/architecture/connector-market.md`,使其与新边界一致。
## 14. 切换策略
实现过程可以分阶段合入,但对 Agent 的最终公共行为必须一次切换:
1. 先构建 MCP Registry、Server、Agent 注入和 CLI shim,旧 Prompt 仍不切流。
2. 使用集成测试验证 native MCP、CLI 和 Skill 三条路径。
3. 更新 Prompt 和 policy,让 Agent 只走原生路径。
4. 同一版本删除四个公开命令及其 runtime allowlist。
5. 已存在的旧 Agent Session 通过 resume 或重启获得新的 MCP binding 和 Prompt。
6. 删除无调用方的内部 Broker 兼容实现。
切换期间不得把 native MCP 调用失败静默回退到 `connector invoke`,否则无法发现
Provider 注入或动态刷新问题。
## 15. 验证方案
### 15.1 Registry 单元测试
- managed stdio 和 remote MCP 都注册为 MCP route,而不是 command capability;
- add、remove、atomic swap 和 generation fence 正确;
- 工具前缀、排序、schema 和冲突校验正确;
- 失败的新 generation 不影响旧 route;
- 实际工具列表变化才触发 list-changed;
- 已 fence route 的调用返回标准 MCP 错误。
### 15.2 MCP Server 测试
- `server/discover -> tools/list -> tools/call` 完整链路;
- Provider 原生 `initialize -> tools/list -> tools/call` 完整链路;
- Provider 原生 `resources/list`、`resources/templates/list` 返回空列表;
- 每次请求的 `_meta` 和 MCP header 一致性校验;
- 本地工具名正确映射回上游原始名称和 arguments;
- 上游 content、structured result、`isError` 和 transport error 保持正确语义;
- invalid token 被拒绝,相同 Session 重签、Session 清理和 `RevokeAll` 立即使旧 token
失效;
- 非 loopback Host、非法 Origin 被拒绝;
- HTTP `GET` / `DELETE` 和未声明的方法被拒绝;
- `subscriptions/listen` 先发送 acknowledged,Connector 更新后发送 list-changed,
binding 撤销后结束订阅。
### 15.3 Provider 测试
- ACP `session/new` 和 `session/load` 收到非空 `mcpServers`;
- ACP 明确声明 HTTP MCP capability 时注入完整 Connector 上下文;
- ACP 未声明、声明 false 或能力探测失败时不绑定 Connector,普通 Session 正常启动;
- 未实现 Connector capability contract 的未来 Provider 默认按不支持降级;
- Codex Session 配置包含 `[mcp_servers.connector]`;
- Claude SDK Session options 包含 `connector` binding;
- Tutti Agent Session 配置包含 `[mcp_servers.connector]`;
- DefaultPreparer 的最终返回保留 authoritative `MCPServers`;
- Connector 在 Agent 已运行后接入,工具无需新建 Session 即可出现;
- Provider 不支持标准通知时按定义的兼容层刷新。
### 15.4 CLI 和 Skill 测试
- shim 正确透传 argv、stdio、信号和退出码;
- release 更新后稳定命令自动指向新 generation;
- 卸载或鉴权失效后 shim 不执行过期 release;
- Connector Skill 能被各 Provider 原生 scanner 发现和读取;
- Prompt 不再引用四个已删除命令。
### 15.5 公共边界测试
- Connector Broker 只注册 `connector.available`;
- `available.interfaces` 正确描述 MCP 和 CLI;
- `available` 不返回 URL、端口、token、headers、OAuth 信息或 release 路径;
- 四个旧命令返回 command-not-found;
- 用户自定义 MCP 不出现在 `connector` Server 的 `tools/list` 中。
建议按改动范围运行:
```bash
go test ./packages/connector/runtime/...
go test ./packages/agent/runtimeprep/...
go test ./packages/agent/daemon/runtime/...
go test ./services/tuttid/service/connectormarket/...
go test ./services/tuttid/service/agent/...
go test ./services/tuttid/...
```
并运行仓库已有的 lint、类型检查以及 Connector managed stdio/remote MCP
集成复现命令。
## 16. 风险和约束
### 16.1 Provider 动态刷新差异
不同 Provider 对 `notifications/tools/list_changed` 的支持可能不一致。必须分别
验证,降级到 Provider reload 或下一轮重连,但不能降级回私有 invoke。
### 16.2 工具名限制
增加 connector key 前缀后可能超过 Provider 限制。注册阶段应确定统一的最大长度
和 canonicalization 规则,不能在不同 Provider adapter 中分别截断。
### 16.3 在途调用与更新
route 更新必须依赖 immutable binding 和 generation fence。不能关闭旧 client 后
再决定调用属于哪个 generation。
### 16.4 CLI 合约迁移
JSON stdin typed commands 不能自动视为正常 CLI。每个受影响连接器都必须有明确的
命令行合约和 Skill 示例后才能删除旧 invoke 路径。
### 16.5 已物化 Skill 内容
Provider 已经读取到当前上下文的 Skill 无法被动态撤回。运行时 MCP/CLI route 和
当前账户的 Connector 发布状态必须作为最终可用性边界,不能仅依赖 Prompt。本地
Session token 只隔离连接和生命周期,不承担 Connector 级权限。
### 16.6 旧文档和测试形成错误事实
现有 `connector-market.md`、Connector policy 和测试明确宣称五个公开命令。实现
完成时必须同步修改,否则后续维护会重新引入已下线的私有调用模型。
## 17. 完成定义
满足以下条件后方案才算完成:
- Agent 通过本地 `connector` MCP Server 原生发现并调用所有 Connector MCP;
- Connector MCP 支持不重启 daemon 的动态注册、更新和移除;
- Agent Session 可以感知工具列表变化;
- CLI 只通过连接器专属命令正常执行;
- Skill 只通过 Agent 原生机制发现和读取;
- 用户自定义 MCP 与 Connector MCP 隔离;
- 公共命令只剩 `tutti connector available`;
- 首期没有新增 enable/disable 状态或入口;
- 安全、生命周期、Provider 和端到端测试全部通过;
- 架构文档、Prompt、policy、CLI help 和实现保持一致。
@@ -0,0 +1,122 @@
# Windows Agent Runtime Setup Reliability
## Background and goal
Windows Agent installation currently depends on three independent conditions:
the daemon must inherit the same outbound proxy behavior as Desktop, the `uv`
bootstrap download must reach GitHub, and every consumer must select the same
installed agent executable. The goal is to remove the avoidable `uv` network
dependency, make static system-proxy handling symmetric with macOS, and prevent
stale Windows shims from splitting status/login/model/session behavior.
## Current architecture and chain
```text
Desktop -> tuttid -> Target installer -> download uv -> uv installs Python agent
-> provider resolver -> PATH/npm shim -> status/login/session
```
- macOS system proxy is converted to process proxy environment; Windows only
receives explicit environment variables.
- `uv` is pinned and verified, but downloaded dynamically on first use.
- OpenCode installation may produce a complete managed npm package while an
older PATH shim remains visible to some consumers.
- Kimi versions before `0.34.0` can initialize ACP yet reject API-key
`session/new` as unauthenticated.
## Target architecture and chain
```text
Desktop package
-> inject packaged uv archive root
-> tuttid verifies cache or packaged archive; download only when absent
-> uv installs the pinned Python agent dynamically
OS proxy adapter -> shared merge/cache -> daemon HTTP + every child process
provider resolver -> one absolute executable
-> status + login + model catalog + ACP session
```
## Repository and module changes
| Repository/module | Change |
| --- | --- |
| `tutti` Desktop packaging | Stage one official compressed `uv` archive per packaged architecture; inject `TUTTI_BUNDLED_UV_ROOT`. |
| `tutti` agent extension service | Verify size/SHA, safely extract packaged `uv`, keep verified cache and network fallback. |
| `tutti` runtime command | Split native proxy reads into Darwin/Windows/other build-tagged files; keep merge policy shared. |
| `tutti` agent status | Resolve a complete managed npm binary once and reuse it across OpenCode consumers. |
| Kimi extension | Pin install to `0.34.0`, require local `>=0.34.0 <1.0.0`, and verify API-key `session/new`. |
| Hermes extension | Document that the host owns `uv`; keep Hermes/Python installation dynamic and the package declarative. |
No public HTTP API or persisted-data schema is added. The only new interface is
the internal Desktop-to-daemon environment variable `TUTTI_BUNDLED_UV_ROOT`.
## Reuse and adapter boundary
Proxy precedence, bypass normalization, caching, child-process injection, and
HTTP transport behavior remain shared. Native adapters only read OS settings.
The existing UV artifact catalog, checksum, safe archive extraction, and
verified cache are reused; Desktop owns resource layout, while tuttid never
depends on Electron paths directly.
## Explicitly out of scope
- Bundling Node, Python, Kimi, Hermes, OpenCode, or their dependency graphs.
- Executing Windows PAC scripts or translating SOCKS proxies.
- Changing user credentials or publishing managed agent commands globally.
- Adding a new public service/API or migrating persisted records.
## Migration and compatibility
There is no data migration. Existing verified UV caches continue to win. A
package without the new resource uses the existing signed dynamic download.
Explicit proxy environment variables and `TUTTI_DISABLE_PROXY_AUTODETECT`
retain precedence. Kimi installations below `0.34.0` are not reused; the Target
installer creates/updates its isolated pinned runtime without overwriting the
user's global Kimi installation.
## Risk and rollback
- Package size grows by about 22–26 MiB compressed per shipped architecture.
Production architecture-specific builds stage one archive; the local
`MAC_ARCH=all` convenience build stages both Darwin archives into every
artifact produced by that combined invocation.
- Native proxy state can be stale for at most the existing 60-second cache TTL.
- A corrupt packaged UV archive is never executed; tuttid falls back to the
same checksum-verified official download and reports both causes if that
download also fails.
Rollback is a normal code/package rollback: remove the Desktop resource and
environment injection to restore dynamic UV download; revert the Windows
adapter to restore explicit-env-only proxy behavior; revert the Kimi pin if the
upstream runtime regresses. No database rollback is required.
## Tests and acceptance
- Parser/precedence tests cover macOS and Windows proxy forms, bypasses, SOCKS
rejection, and explicit-env priority; Windows and Darwin files compile.
- UV tests prove a verified bundle performs zero network requests and corrupt
bundles are rejected; release staging verifies the real official archive.
- OpenCode tests plant a stale shim plus complete managed package and require
the managed absolute command.
- Kimi tests require pinned version, API-key ACP `session/new`, commands, and
Skill discovery.
- Packaged Windows acceptance blocks GitHub UV downloads, installs Hermes, and
completes ACP initialize/session creation through the configured package
index.
## Phased order
1. Land host proxy, bundled UV, and OpenCode resolution with compatibility
fallback.
2. Land Kimi minimum-version/package pin and its ACP regression test.
3. Land Hermes host-contract documentation and run packaged Windows acceptance.
4. Monitor integrity/proxy diagnostics; promote Windows packaging only through
its existing release gates.
## Pending confirmation
- Whether PAC support is required for managed enterprise Windows deployments.
- Final installer-size budget and whether universal macOS packages may include
both architecture archives.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
# 飞书多维表格四项 AgentGUI 问题修复说明
日期:2026-08-12
## 范围与结论
四条现象位于同一 `tutti-os/tutti` monorepo,但属于四条独立数据链路:
1. 删除项目与会话生命周期脱节:未置顶会话应删除,置顶会话应保留并把底层归属迁到 Chats,否则取消置顶后没有可展示分区。
2. Claude WebFetch 授权事件带有 URL,但 daemon 归一化丢弃 `toolCall.input`,授权卡片读取不到 URL。
3. Claude SDK 的“本次会话允许”只更新当前 Query;Tutti 每个 settled Turn 后创建 fresh Query,权限没有跨 Query 延续。
4. Codex 计划完成后的会话内提示曾未进入 Message Center;当前 `origin/main` 已由 `a95fa07a1` 修复,本次只做完整链路验证。
## 1. 删除项目后取消置顶导致会话消失
### 调用链与根因
项目删除确认后的旧调用链仅执行 `userProjects.remove(path)`。仍置顶的 Session 继续由
Pinned 投影展示,掩盖了项目记录已不存在的事实;取消置顶后,它重新按持久化的旧
`rail_section_key` 投影,而对应项目模板已经删除,最终无处展示。
直接原因是 Remove project 没有调用未置顶 Session 删除链路,也没有迁移 pinned
Session 的 canonical rail owner。系统性原因是 renderer、项目元数据与 canonical
Session 生命周期之间没有一个带条件检查的权威事务边界。
### 修复
GUI 只提交并等待一个权威的 `userProjects.remove(path)` 操作。服务端在 SQLite 中按
项目 canonical key 查找所有 workspace 的未置顶 root Session,并交给 Agent Host 的
批量删除协调器关闭运行时、展开子会话闭包、写入可恢复 tombstone 并清理资源。随后
服务端重试一个 compare-and-finalize 事务:若期间又出现未置顶 Session 就继续删除;
否则在同一事务里把剩余 live pinned Session(以及可恢复 tombstone)的
`rail_section_kind/path/key` 改为 Chats,并删除项目元数据。
Session 首次 canonical 落库也会在事务内校验显式 project placement 是否仍注册;删除
并发期间携带旧 placement 的晚到 Session 会归入 Chats,不会重建孤儿项目 rail。
### 影响与验证
- 影响:该项目精确 canonical section 下所有 workspace/provider/agent target 的 Session;未置顶 Session 删除,置顶 Session 保留并归入 Chats。
- 不影响:其他项目和 Chats;现有 Session 删除保护、子会话闭包及资源清理语义保持不变。
- 验证:断言事务计划只返回未置顶 root;Agent Host 删除完成前项目不移除;finalize 后 pinned 状态、时间和内容不变但 canonical rail 为 Chats;未置顶 tombstone 恢复时也归 Chats;晚到 stale placement 不会重建项目 rail。
## 2. Claude WebFetch 授权卡片缺少 URL
### 调用链与根因
Claude SDK `canUseTool(WebFetch, input)` → sidecar `approval_requested.payload.toolCall.input`
→ daemon `normalizedApprovalInput` → canonical Interaction → AgentGUI 授权卡片。
sidecar 已同时发送根级 `input` 和 `toolCall.input`。旧 daemon 只复制 toolCall 的
ID、名称、状态等 identity 字段,明确丢弃了嵌套 input;同时顶层字段 fallback 列表
也没有 `url`/`uri`。GUI 展示模型读取 canonical `toolCall.input`,因此 URL 缺失。
直接原因是归一化字段白名单不完整。系统性原因是 producer 与 consumer 对授权展示
模型的契约不一致,缺少 WebFetch 形状的边界测试。
### 修复
daemon 将清洗后的 display input 同时放入 canonical 根 input 和
`toolCall.input`,继续排除 raw/provider debug/content 等非展示字段;并把 `url`、`uri`
加入旧 provider 顶层形状的 fallback。
### 影响与验证
- 影响:Claude、Codex、ACP 共用的 approval canonical projection;属于加法字段兼容。
- 风险控制:只复制清洗后的展示 input,不重新引入 provider debug 大对象。
- 验证:Claude WebFetch 标准嵌套 input、顶层 URL fallback、原有 edit diff 归一化测试。
## 3. “本次会话允许”未跨 fresh Query 延续
### 调用链与根因
用户点击 `allow_always` → sidecar 返回 SDK `updatedPermissions=suggestions` → 当前 Claude
Query 内存权限更新 → Turn settled → `SessionRuntime` 关闭 Query → 下一次用户消息创建
fresh Query。
Claude SDK 的 `updatedPermissions` 只对收到返回值的 Query 生效。SessionRuntime 会跨
Turn 复用,但旧实现没有保存 SDK suggestion,所以 fresh Query 再次请求同一权限。
直接原因是权限更新生命周期绑定 Query。系统性原因是 UI 的“会话”语义与 SDK 的
Query 生命周期没有适配层。
### 修复
在 `SessionRuntime` 生命周期持有的 `InteractiveCoordinator` 内增加有界内存 ledger:
- 只记录 SDK 原样建议且全部 `destination=session` 的批次;
- 使用稳定 canonical JSON 做精确匹配,不解析或扩大 rule/path/domain 语义;
- fresh Query 再次提出完全相同的 suggestion 时,自动返回该 Query 的
`updatedPermissions`,不再发授权卡片;
- `allow_once`、持久化 destination、混合 destination、空 suggestion 或不完全匹配仍然询问;
- ledger 不落盘,SessionRuntime 销毁即失效,并设置容量上限。
### 影响与验证
- 影响:仅 Claude SDK sidecar 的普通 tool permission。
- 不影响:AskUserQuestion、ExitPlanMode、bypassPermissions 和其他 provider。
- 验证:ledger 精确匹配/拒绝持久 scope、allow-once 不记忆,以及两个连续 Turn 使用两个 fresh Query 的集成测试。
## 4. Codex 计划提示未进入 Message Center
### 当前基线链路
当前 `origin/main` 已具备完整链路:
`codex_appserver_event_items.go` 将 completed plan item 投影为
`messageKind=plan` → canonical Session/Turn/Message 进入 Activity Engine →
共享 `consumerAwaitingPlanImplementation` 判断已完成 plan 是否待实施 →
`workspaceAgentMessageCenterEngineModel` 生成 `kind=plan-implementation` attention target →
Message Center 与右侧浮层消费同一模型。
修复来自提交 `a95fa07a1`,本分支不重复改写该模型。
### 影响与验证
- 影响:支持 `planImplementation` capability 的 provider;Codex app-server 已声明该能力。
- 验证:Codex completed plan event 生成 tagged plan message、共享 awaiting selector、Message Center canonical target、非当前会话保持 waiting 的三组测试。
## 风险与回滚边界
- 数据迁移发生在项目删除事务中,失败会阻止项目删除,不会留下半完成状态。
- approval projection 是加法变化;旧 consumer 忽略新嵌套字段仍可工作。
- Claude ledger 只驻留内存且精确匹配,主要剩余风险是未来 SDK 改变 suggestion 结构后回退为再次询问,而不是误授权。
- 第 4 条没有本分支运行时代码变化,风险来自基线能力本身,由现有回归覆盖。
@@ -0,0 +1,53 @@
# Exact Connector CLI execution
## Goal
Products need one host-neutral primitive for executing a managed Connector CLI
without turning Connector sharing into remote shell access. The product owns
session authorization and transport; Tutti owns verified process resolution and
route lifecycle.
## Contract
`implementationhost.Host.StartCLI` accepts:
- connection ID and Connector key;
- Connector version and release digest;
- host boot epoch and generation;
- the derived managed CLI contract hash;
- bounded user arguments.
It does not accept executable paths, working directories, environment
variables, state directories, artifact roots, or arbitrary process specs. The
host resolves those values from the exact current route and rejects stale or
ambiguous identity. Route retirement fences pending starts and closes active
processes. The returned connection retains context-aware receive and graceful
stdin/terminate/kill capabilities when the underlying transport provides them;
closing it releases the route-owned process exactly once.
`host.ManagedCLIContractHash` hashes versioned invocation semantics. It excludes
entrypoint/install paths and descriptions, and includes fixed arguments,
command argument mappings, input schemas, and timeouts. Hosts publish the hash
with the immutable route descriptor and require an exact match at execution.
## Cross-machine boundary
A product that shares Connector CLI authority must define a typed Connector CLI
protocol. Across machines the protocol carries only the public Connector key,
version and command, plus bounded argv/stdin/output frames, cancellation, and a
stable operation ID. Runtime boot epoch, generation, release digest and contract
hash are machine-local identities: the receiving product resolves and pins its
own current route at execution time and passes that exact local identity to
`StartCLI`. The sharing product freezes the public CLI surface allowed by the
Owner's invocation policy and protects each route with an invocation-scoped
gateway capability. The existing authenticated invocation/command-route lease
authorizes the P2P channel; no server-side Connector CLI catalog or grant is
created. It must never accept an executable or runtime
incarnation from the peer, or reuse a broad arbitrary-command capability as the
authorization decision.
Presentation shims may expose a native command name, but a route-only shim must
dispatch to the product broker. The shim is not proof of authority. Owner is
the default; products may expose an explicit caller-authority selector, and
must fail closed on invalid values or unavailable routes without falling back
to Owner.
+24
View File
@@ -0,0 +1,24 @@
# Active Specs And Plans
This directory contains dated work that is still proposed or in progress. It
is intentionally small: completed plans are removed once their durable result
is represented by current architecture, conventions, ADRs, and tests. Git
history remains available for implementation archaeology.
Specs are not the source of truth for behavior that has already landed. When a
spec completes, update the current document that owns the result and delete the
dated plan.
There are currently eleven active specs:
- [Agent Provider Status Read/Detect Split](./2026-06-28-agent-status-read-detect-split-design.md): pending review.
- [Agent Extension Package Design](./2026-07-14-agent-extension-package-design.md): pending architecture and implementation approval.
- [Provider-Native Subagents](./2026-07-15-provider-native-subagents.md): accepted architecture, implementation in progress.
- [Agent Goal Control Design](./2026-07-15-agent-goal-control-design.md): implemented, pending final review and merge.
- [Mobile AgentGUI And DeviceLink Design](./2026-07-23-mobile-agentgui-device-link-design.md): accepted architecture; Android M0 transport slice passed, physical-network validation and M1+ remain active.
- [Agent Session Fork Design](./2026-07-27-agent-session-fork-design.md): throughTurn implemented; supersedes the 2026-07-01 draft and implementation plan.
- [Tutti Agent `skills/list` Integration](./2026-07-30-tutti-agent-skills-list-integration.md): implemented; local Desktop end-to-end validation passed, pending cross-platform artifact validation.
- [Agent Side Conversation Technical Design](./2026-07-28-agent-side-conversation-technical-design.md): runtime-only active-Turn Side architecture, provider contract, Codex reference flow, and delivery phases.
- [Connector Market Shared Domain](./2026-08-03-connector-market-shared-domain.md): signed shared core, Tutti daemon implementation host, and renderer service implemented; credential-backed connectors remain outside the initial compatibility boundary.
- [Agent Conversation Activity View PRD](./2026-08-05-agent-conversation-activity-view-prd.md): implemented, pending code review.
- [Connector Authorization UI Protocol](./2026-08-12-connector-authorization-interaction.md): accepted configuration-first protocol; protocol, declarative adapter, and replaceable renderer implementation in progress.
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="760" viewBox="0 0 1440 760">
<rect width="1440" height="760" fill="#0b1020"/>
<text x="72" y="86" fill="#f8fafc" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="38" font-weight="700">Codex 省额度模式 · 关闭态对照证据</text>
<text x="72" y="130" fill="#94a3b8" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="20">Session 48d15900-9423-4d95-9923-a14853b8ca9e · outcome=completed · childSessions=1</text>
<rect x="72" y="182" width="1296" height="300" rx="18" fill="#241921" stroke="#64748b"/>
<text x="112" y="240" fill="#fda4af" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="26">codexSaverMode = false</text>
<text x="112" y="300" fill="#e2e8f0" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="26">main model = gpt-5.6-sol reasoning = low</text>
<text x="112" y="360" fill="#e2e8f0" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="26">subagent model = gpt-5.6-sol reasoning = low</text>
<text x="112" y="420" fill="#e2e8f0" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="26">agents/luna_worker.toml = absent</text>
<text x="72" y="558" fill="#cbd5e1" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="22">证据源:Session API、会话级文件系统与 CODEX_HOME/state_5.sqlite。</text>
<text x="72" y="620" fill="#22c55e" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="27" font-weight="700">结论:关闭后恢复原行为,无 Luna 配置残留</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="760" viewBox="0 0 1440 760">
<rect width="1440" height="760" fill="#0b1020"/>
<text x="72" y="86" fill="#f8fafc" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="38" font-weight="700">Codex 省额度模式 · 开关记忆证据</text>
<text x="72" y="132" fill="#94a3b8" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="21">应用与 daemon 完整重启后再次读取</text>
<rect x="72" y="184" width="1296" height="330" rx="18" fill="#111a31" stroke="#334155"/>
<text x="112" y="244" fill="#c4b5fd" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="26">featureFlags.lab.codexSaverMode = true</text>
<text x="112" y="306" fill="#c4b5fd" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="26">agentComposerDefaultsByAgentTarget.local:codex.codexSaverMode = true</text>
<text x="112" y="368" fill="#c4b5fd" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="26">composer.codexSaverModeSupported = true</text>
<text x="112" y="430" fill="#c4b5fd" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="26">composer.effectiveSettings.codexSaverMode = true</text>
<text x="112" y="492" fill="#93c5fd" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="26">composer.effectiveSettings.model = gpt-5.6-sol</text>
<text x="72" y="590" fill="#cbd5e1" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="22">证据源:真实 Preferences API 与 Composer Options API;目标级记忆,不影响主模型选择。</text>
<text x="72" y="652" fill="#22c55e" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="27" font-weight="700">结论:跨新会话、应用重启后仍保持开启</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="760" viewBox="0 0 1440 760">
<rect width="1440" height="760" fill="#0b1020"/>
<text x="72" y="86" fill="#f8fafc" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="38" font-weight="700">Codex 省额度模式 · 真实运行证据</text>
<text x="72" y="130" fill="#94a3b8" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="20">Session ce09a5f5-17a3-4aaf-aee7-1ffe30eecf02 · outcome=completed · childSessions=1</text>
<rect x="72" y="182" width="1296" height="164" rx="18" fill="#111a31" stroke="#334155"/>
<text x="112" y="230" fill="#93c5fd" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="25" font-weight="700">主线程(保留用户选择)</text>
<text x="112" y="281" fill="#e2e8f0" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="29">model = gpt-5.6-sol</text>
<text x="760" y="281" fill="#e2e8f0" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="29">reasoning = low</text>
<rect x="72" y="382" width="1296" height="204" rx="18" fill="#10251f" stroke="#22c55e" stroke-width="2"/>
<text x="112" y="432" fill="#86efac" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="25" font-weight="700">独立子线程(默认 Luna worker)</text>
<text x="112" y="486" fill="#dcfce7" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="29">thread_source = subagent</text>
<text x="112" y="536" fill="#dcfce7" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="29">model = gpt-5.6-luna</text>
<text x="760" y="536" fill="#dcfce7" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="29">reasoning = max</text>
<text x="72" y="650" fill="#cbd5e1" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="22">证据源:会话级 CODEX_HOME/state_5.sqlite 的 threads 表(不是模型自报)</text>
<text x="72" y="696" fill="#22c55e" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-size="27" font-weight="700">结论:通过</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB