98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
357 lines
16 KiB
Markdown
357 lines
16 KiB
Markdown
# Session Protocol Unification v2 — Draft
|
|
|
|
Status: **DRAFT**
|
|
|
|
## Overview
|
|
|
|
This document captures the proposed unified session protocol event types, incorporating feedback on top of the original `session-protocol-unification.md` plan. The goal is the same — all agents emit a single envelope format, all legacy senders die — but the event type surface has been redesigned.
|
|
|
|
For the migration mechanics (phases, task lists, app-side cleanup), see the original plan (stashed as `session-protocol-unification.md`).
|
|
|
|
## Envelope (unchanged)
|
|
|
|
```json
|
|
{
|
|
"id": "<cuid2>",
|
|
"time": 1739347200000,
|
|
"role": "user" | "agent",
|
|
"turn": "<cuid2>",
|
|
"subagent": "<cuid2>",
|
|
"ev": { "t": "...", ... }
|
|
}
|
|
```
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `id` | cuid2 | Globally unique message identifier |
|
|
| `time` | number | Unix timestamp in milliseconds |
|
|
| `role` | `"user"` \| `"agent"` | Who produced this event |
|
|
| `turn` | cuid2? | Turn id established by `turn-start`. Required on all agent messages during a turn |
|
|
| `subagent` | cuid2? | Subagent identifier — matches `agentId` from `subagent-start` |
|
|
| `ev` | object | Event body, discriminated by `ev.t` |
|
|
|
|
## Event Types (14)
|
|
|
|
### Content
|
|
|
|
**`text`** — user or agent
|
|
|
|
```json
|
|
{ "t": "text", "text": "Hello, how can I help?", "thinking": true }
|
|
```
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `text` | string | Message text (markdown) |
|
|
| `thinking` | boolean? | Agent only. `true` for internal reasoning |
|
|
| `steer` | boolean? | User only. Mid-turn injection — picked up between LLM calls. Default user messages queue for next turn |
|
|
|
|
Client-side ordering note: steer messages are displayed immediately but the agent hasn't picked them up yet. The app pins them below current agent output until `turn-end` or until the agent emits output that follows the steer. No protocol-level ordering needed — client display logic handles this.
|
|
|
|
**`file`** — user or agent
|
|
|
|
```json
|
|
{ "t": "file", "ref": "upload_def", "name": "report.pdf", "size": 524288, "image": { "width": 800, "height": 600, "thumbhash": "..." } }
|
|
```
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `ref` | string | Server upload ID |
|
|
| `name` | string | Display filename |
|
|
| `size` | number | File size in bytes |
|
|
| `image` | object? | Optional image metadata |
|
|
| `image.width` | number | Image width in pixels |
|
|
| `image.height` | number | Image height in pixels |
|
|
| `image.thumbhash` | string | Base64-encoded ThumbHash for instant placeholder |
|
|
|
|
### Turn Lifecycle
|
|
|
|
**`turn-start`** — agent
|
|
|
|
```json
|
|
{ "t": "turn-start" }
|
|
```
|
|
|
|
Establishes the `turn` id on the envelope. Also serves as the "agent picked up your message" signal — the gap between user `text` (sent) and `turn-start` is the "queued" window.
|
|
|
|
**`turn-end`** — agent
|
|
|
|
```json
|
|
{ "t": "turn-end", "status": "completed", "usage": { "inputTokens": 2400, "outputTokens": 180, "cost": 0.012 } }
|
|
```
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `status` | `"completed"` \| `"failed"` \| `"cancelled"` | Final turn outcome |
|
|
| `usage` | object? | Token usage for this turn |
|
|
| `usage.inputTokens` | number | Input tokens consumed |
|
|
| `usage.outputTokens` | number | Output tokens generated |
|
|
| `usage.cost` | number | Cost in USD |
|
|
|
|
Replaces the ephemeral `usage-report` socket event. Also replaces the legacy `ready` signal — `turn-end` IS the ready signal.
|
|
|
|
### Tool Calls
|
|
|
|
**`tool-call-start`** — agent
|
|
|
|
```json
|
|
{ "t": "tool-call-start", "call": "tc_abc", "name": "grep", "title": "Searching for handleClick", "description": "Searching for `handleClick` in **src/**", "args": { "pattern": "handleClick" } }
|
|
```
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `call` | string | Tool call identifier, matched by `tool-call-end` |
|
|
| `name` | string | Tool name (lowercase, hyphenated) |
|
|
| `title` | string | Short summary (inline markdown) |
|
|
| `description` | string | Full description (inline markdown) |
|
|
| `args` | object | Tool input arguments |
|
|
|
|
**`tool-call-end`** — agent
|
|
|
|
```json
|
|
{ "t": "tool-call-end", "call": "tc_abc" }
|
|
```
|
|
|
|
### Service
|
|
|
|
**`service`** — agent
|
|
|
|
```json
|
|
{ "t": "service", "text": "Context window compacted" }
|
|
```
|
|
|
|
System/internal messages — reconnecting, context compacted, process errors. Not from the LLM.
|
|
|
|
### Subagent
|
|
|
|
**`subagent-start`** — agent
|
|
|
|
```json
|
|
{ "t": "subagent-start", "agentId": "sa_abc123", "title": "Research agent" }
|
|
```
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `agentId` | string | Subagent identifier — same value used in envelope `subagent` field on all messages this subagent produces |
|
|
| `title` | string? | Human-readable label |
|
|
|
|
**`subagent-stop`** — agent
|
|
|
|
```json
|
|
{ "t": "subagent-stop", "agentId": "sa_abc123" }
|
|
```
|
|
|
|
### Session Control
|
|
|
|
**`session-control-changed`** — user or agent
|
|
|
|
```json
|
|
{ "t": "session-control-changed", "clientType": "phone", "tokenId": "tok_abc123" }
|
|
```
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `clientType` | `"cli"` \| `"phone"` \| `"browser"` \| `"daemon"` | What took control |
|
|
| `tokenId` | string? | Auth token identifying the device/session. Used for push notification routing — don't send to phone if browser has control |
|
|
|
|
Controlling device is also written to session state (metadata) as a rollup of the stream.
|
|
|
|
### Permissions
|
|
|
|
**`permission-request`** — agent
|
|
|
|
```json
|
|
{
|
|
"t": "permission-request",
|
|
"call": "tc_abc",
|
|
"toolName": "bash",
|
|
"title": "Run `rm -rf node_modules`",
|
|
"description": "Delete node_modules directory",
|
|
"args": { "command": "rm -rf node_modules" }
|
|
}
|
|
```
|
|
|
|
Always during a turn. Appears between `tool-call-start` and `tool-call-end`. The tool does not execute until the user responds. RPC still delivers in real-time, but the message is the permanent record in the stream.
|
|
|
|
**`permission-response`** — user
|
|
|
|
```json
|
|
{ "t": "permission-response", "call": "tc_abc", "decision": "allow-once" }
|
|
```
|
|
|
|
Matches the `call` from the request.
|
|
|
|
### Abort
|
|
|
|
**`abort`** — user
|
|
|
|
```json
|
|
{ "t": "abort" }
|
|
```
|
|
|
|
Written to the stream when user presses stop. RPC still does real-time delivery. Agent responds with `turn-end { status: "cancelled" }`.
|
|
|
|
### Configuration
|
|
|
|
**`agent-configuration-changed`** — user or agent
|
|
|
|
```json
|
|
{ "t": "agent-configuration-changed", "model": "claude-sonnet-4-6", "thinkingLevel": "high" }
|
|
```
|
|
|
|
All fields optional — include only what changed:
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `permissionMode` | string? | `"default"` \| `"acceptEdits"` \| `"bypassPermissions"` \| `"plan"` |
|
|
| `model` | string? | Model identifier |
|
|
| `thinkingLevel` | string? | Thinking budget level |
|
|
| `sandbox` | boolean? | Sandbox enabled |
|
|
|
|
User sends this when changing config from the app. Agent sends this when entering plan mode, etc. The actual config mechanism stays in session metadata (optimistic concurrency) — this event is the audit trail in the stream so you can scroll back and see "model was switched to Sonnet here."
|
|
|
|
## Event Type Summary
|
|
|
|
| Category | `ev.t` | Role | New? |
|
|
|----------|--------|------|------|
|
|
| Content | `text` | user/agent | modified (added `steer`) |
|
|
| Content | `file` | user/agent | existing |
|
|
| Turn | `turn-start` | agent | existing |
|
|
| Turn | `turn-end` | agent | modified (added `usage`) |
|
|
| Tool calls | `tool-call-start` | agent | existing |
|
|
| Tool calls | `tool-call-end` | agent | existing |
|
|
| Service | `service` | agent | existing |
|
|
| Subagent | `subagent-start` | agent | renamed, added `agentId` |
|
|
| Subagent | `subagent-stop` | agent | renamed, added `agentId` |
|
|
| Session | `session-control-changed` | user/agent | new |
|
|
| Permissions | `permission-request` | agent | new |
|
|
| Permissions | `permission-response` | user | new |
|
|
| Abort | `abort` | user | new |
|
|
| Config | `agent-configuration-changed` | user/agent | new |
|
|
|
|
## Removed
|
|
|
|
| What | Why |
|
|
|------|-----|
|
|
| `ready` signal (`sendSessionEvent({ type: 'ready' })`) | Redundant with `turn-end` |
|
|
| `start` / `stop` event names | Renamed to `subagent-start` / `subagent-stop` |
|
|
| `machineId` on control-changed | Replaced by `tokenId` — auth token already identifies the device |
|
|
| Ephemeral `usage-report` socket event | Moved into `turn-end.usage` |
|
|
|
|
## Realistic Flow
|
|
|
|
User opens session on phone, agent runs on CLI, user steers mid-turn, switches model, aborts.
|
|
|
|
```
|
|
// CLI starts, takes control
|
|
← { id: "m1", time: 1000, role: "agent", ev: { t: "session-control-changed", clientType: "cli", tokenId: "tok_laptop" } }
|
|
|
|
// User sends prompt from phone
|
|
← { id: "m2", time: 2000, role: "user", ev: { t: "text", text: "Find all TODO comments and fix the critical ones" } }
|
|
|
|
// Agent picks it up
|
|
← { id: "m3", time: 2500, role: "agent", turn: "t1", ev: { t: "turn-start" } }
|
|
← { id: "m4", time: 2501, role: "agent", turn: "t1", ev: { t: "text", text: "I'll search for TODOs first." } }
|
|
← { id: "m5", time: 2502, role: "agent", turn: "t1", ev: { t: "tool-call-start", call: "c1", name: "grep", title: "Searching for TODO", description: "Searching for `TODO` in **src/**", args: { "pattern": "TODO", "path": "src/" } } }
|
|
← { id: "m6", time: 2503, role: "agent", turn: "t1", ev: { t: "tool-call-end", call: "c1" } }
|
|
← { id: "m7", time: 2504, role: "agent", turn: "t1", ev: { t: "text", text: "Found 12 TODOs. Starting with the auth module..." } }
|
|
|
|
// User steers mid-turn — agent hasn't seen this yet
|
|
← { id: "m8", time: 2600, role: "user", ev: { t: "text", text: "Skip auth, focus on the payment module instead", steer: true } }
|
|
|
|
// Agent continues current LLM call, then picks up steer
|
|
← { id: "m9", time: 2700, role: "agent", turn: "t1", ev: { t: "tool-call-start", call: "c2", name: "edit", title: "Editing **src/payment/checkout.ts**", description: "Fixing TODO in payment module", args: { "file": "src/payment/checkout.ts" } } }
|
|
|
|
// Agent needs permission for a destructive edit
|
|
← { id: "m10", time: 2701, role: "agent", turn: "t1", ev: { t: "permission-request", call: "c2", toolName: "edit", title: "Editing **src/payment/checkout.ts**", description: "Replacing TODO with retry logic", args: { "file": "src/payment/checkout.ts" } } }
|
|
|
|
// User approves
|
|
← { id: "m11", time: 3000, role: "user", ev: { t: "permission-response", call: "c2", decision: "allow-once" } }
|
|
|
|
// Tool executes
|
|
← { id: "m12", time: 3001, role: "agent", turn: "t1", ev: { t: "tool-call-end", call: "c2" } }
|
|
|
|
// Agent spawns a subagent to research the second TODO
|
|
← { id: "m13", time: 3002, role: "agent", turn: "t1", ev: { t: "subagent-start", agentId: "sa_research", title: "Payment research" } }
|
|
← { id: "m14", time: 3003, role: "agent", turn: "t1", subagent: "sa_research", ev: { t: "text", text: "Looking at payment retry patterns..." } }
|
|
← { id: "m15", time: 3004, role: "agent", turn: "t1", subagent: "sa_research", ev: { t: "tool-call-start", call: "c3", name: "grep", title: "Searching for retry", description: "Searching for retry patterns", args: { "pattern": "retry" } } }
|
|
← { id: "m16", time: 3005, role: "agent", turn: "t1", subagent: "sa_research", ev: { t: "tool-call-end", call: "c3" } }
|
|
← { id: "m17", time: 3006, role: "agent", turn: "t1", subagent: "sa_research", ev: { t: "text", text: "Found existing retry utility." } }
|
|
← { id: "m18", time: 3007, role: "agent", turn: "t1", ev: { t: "subagent-stop", agentId: "sa_research" } }
|
|
|
|
// Turn completes with usage
|
|
← { id: "m19", time: 3008, role: "agent", turn: "t1", ev: { t: "text", text: "Fixed 2 TODOs in the payment module." } }
|
|
← { id: "m20", time: 3009, role: "agent", turn: "t1", ev: { t: "turn-end", status: "completed", usage: { inputTokens: 4800, outputTokens: 620, cost: 0.024 } } }
|
|
|
|
// User switches model from phone
|
|
← { id: "m21", time: 5000, role: "user", ev: { t: "agent-configuration-changed", model: "claude-sonnet-4-6", thinkingLevel: "low" } }
|
|
|
|
// User sends next prompt
|
|
← { id: "m22", time: 5001, role: "user", ev: { t: "text", text: "Now run the tests" } }
|
|
← { id: "m23", time: 5100, role: "agent", turn: "t2", ev: { t: "turn-start" } }
|
|
← { id: "m24", time: 5101, role: "agent", turn: "t2", ev: { t: "tool-call-start", call: "c4", name: "bash", title: "Run `npm test`", description: "Running test suite", args: { "command": "npm test" } } }
|
|
|
|
// User aborts
|
|
← { id: "m25", time: 5200, role: "user", ev: { t: "abort" } }
|
|
← { id: "m26", time: 5201, role: "agent", turn: "t2", ev: { t: "tool-call-end", call: "c4" } }
|
|
← { id: "m27", time: 5202, role: "agent", turn: "t2", ev: { t: "turn-end", status: "cancelled" } }
|
|
|
|
// User opens session from browser — control shifts, push notifications reroute
|
|
← { id: "m28", time: 8000, role: "user", ev: { t: "session-control-changed", clientType: "browser", tokenId: "tok_browser" } }
|
|
```
|
|
|
|
## What Stays on Side Channels
|
|
|
|
| Thing | Mechanism | Why not in stream |
|
|
|-------|-----------|-------------------|
|
|
| Activity/presence | Ephemeral `session-alive` / `session-end` socket events | Too frequent, not worth persisting |
|
|
| Config source of truth | Session metadata with optimistic concurrency | `agent-configuration-changed` is the audit trail, metadata is the mechanism |
|
|
| Permission delivery | RPC for real-time, stream for audit | RPC needed for immediate response, stream needed for history |
|
|
| Abort delivery | RPC for real-time, stream for audit | Same pattern |
|
|
|
|
## Migration — What Needs to Change
|
|
|
|
### CLI legacy senders to remove
|
|
|
|
| Method | Current wire shape | Used by | Action |
|
|
|--------|--------------------|---------|--------|
|
|
| `sendAgentMessage(provider, body)` | `{ role: 'agent', content: { type: 'acp', provider, data } }` | `runGemini.ts` (~25 call sites) | Replace with `AcpSessionManager` → `sendSessionProtocolMessage()` |
|
|
| `sendCodexMessage(body)` | `{ role: 'agent', content: { type: 'codex', data } }` | Dead code — Codex already on envelopes | Delete |
|
|
| `sendSessionEvent(event)` | `{ role: 'agent', content: { type: 'event', data } }` | All runners: ready, errors, mode switch | Delete — `turn-end` replaces ready, `service` replaces error messages |
|
|
| `ACPMessageData` type | — | `apiSession.ts` | Delete with `sendAgentMessage()` |
|
|
| `ACPProvider` type | — | `apiSession.ts` | Delete with `sendAgentMessage()` |
|
|
| `MessageAdapter` class | — | `agent/adapters/MessageAdapter.ts` | Delete — unused once `sendAgentMessage()` is gone |
|
|
|
|
### CLI: Gemini migration
|
|
|
|
`runGemini.ts` is the only runner still on `sendAgentMessage()`. Wire in `AcpSessionManager` (already works for the generic ACP runner and OpenClaw) and route all message dispatch through it.
|
|
|
|
### CLI: user text format
|
|
|
|
The CLI already emits only the modern `role: 'session'` envelope for user text. The `ENABLE_SESSION_PROTOCOL_SEND` flag that was planned as a rollout toggle was never implemented in code — removed from docs.
|
|
|
|
### App legacy branches to remove (in `typesRaw.ts` rawAgentRecordSchema)
|
|
|
|
| Branch | `content.type` | Action |
|
|
|--------|----------------|--------|
|
|
| `acp` | `{ type: 'acp', provider, data }` | Remove after Gemini migration |
|
|
| `codex` | `{ type: 'codex', data }` | Remove — Codex already on envelopes |
|
|
| `event` | `{ type: 'event', data }` | Remove — replaced by `service` and `turn-end` |
|
|
| `output` | Raw Claude JSONL | Keep for historical message replay (oldest format, may exist in production databases) |
|
|
|
|
Also remove the hyphenated content normalization transforms (`normalizeToToolUse()` / `normalizeToToolResult()`) once no active clients send those formats.
|
|
|
|
### happy-wire schema updates
|
|
|
|
- Rename `start`/`stop` → `subagent-start`/`subagent-stop` with `agentId` field
|
|
- Add `usage` to `turn-end` schema
|
|
- Add new event types: `session-control-changed`, `agent-configuration-changed`, `permission-request`, `permission-response`, `abort`
|
|
- Add `steer` field to `text` event schema
|
|
|
|
## Open Questions
|
|
|
|
- **`steer` field naming**: `steer: true` vs `sendType: "steer"` vs `queuing: "immediate"` — bikeshed later
|
|
- **Permission auto-approval**: when a tool is auto-approved (from a previous `allow-always`), emit `permission-request` + `permission-response` to the stream for audit, or skip for noise reduction?
|
|
- **Subagent `agentId` format**: cuid2 like today, or human-readable like `sa_research`? Examples above use readable ids for clarity but production would be cuid2
|
|
- **`session-control-changed` and metadata rollup**: exact fields to mirror in session state TBD
|