mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-07 19:06:11 +00:00
279 lines
17 KiB
Markdown
279 lines
17 KiB
Markdown
# Message decode tree
|
||
|
||
**Audience:** Implementers of native HAPI clients (iOS / Android). This page specifies how to decode `DecryptedMessage.content` into renderable chat structure. This is the largest porting surface — the reference pipeline is `web/src/chat/` (~4600 lines); this page is its wire-level contract. Companion pages: [pagination](./pagination.md) (how messages arrive), [sse](./sse.md) (live delivery).
|
||
|
||
Source of truth: `shared/src/schemas.ts` (`DecryptedMessageSchema`), `shared/src/messages.ts` (envelope helpers), `web/src/chat/normalize.ts`, `web/src/chat/normalizeUser.ts`, `web/src/chat/normalizeAgent.ts`, `web/src/chat/types.ts`, `hub/src/store/contentCodec.ts`.
|
||
|
||
---
|
||
|
||
## Wire shape
|
||
|
||
```ts
|
||
type DecryptedMessage = {
|
||
id: string // server uuid (optimistic rows: == localId until echoed)
|
||
seq: number | null // per-session insert counter
|
||
localId: string | null // client-generated id for optimistic reconciliation
|
||
content: unknown // the role-wrapped envelope — everything below
|
||
createdAt: number // hub receive time (epoch ms)
|
||
invokedAt?: number | null // when the agent consumed it (null = still queued)
|
||
scheduledAt?: number | null // future-scheduled sends
|
||
deliveryState?: 'indeterminate' // steer dispatched, outcome unproven; explicit retry/cancel required
|
||
}
|
||
```
|
||
|
||
`content` is deliberately `unknown` on the wire. **Decoding must be total**:
|
||
never crash on unfamiliar content. Use stringified fallbacks for unknown
|
||
envelopes, and follow the family-specific skip/validation rules below for
|
||
known transport records (see [Fallback rules](#fallback-rules)).
|
||
|
||
---
|
||
|
||
## Envelope
|
||
|
||
`content` is a role-wrapped envelope:
|
||
|
||
```ts
|
||
{ role: 'user' | 'agent', content: <payload>, meta?: unknown }
|
||
```
|
||
|
||
Unwrap algorithm (`unwrapRoleWrappedRecordEnvelope`, `shared/src/messages.ts`) — a record qualifies when it has a string `role` and a `content` key; if `content` itself is not one, also probe, in order:
|
||
|
||
1. `content.message`
|
||
2. `content.data.message`
|
||
3. `content.payload.message`
|
||
|
||
No envelope found ⇒ render the **whole** `content` as stringified agent text. `role` other than `user`/`agent` ⇒ stringify `record.content` as agent text.
|
||
|
||
### `meta`
|
||
|
||
Opaque record; carry it through. Known keys:
|
||
|
||
| Key | Values | Meaning |
|
||
|---|---|---|
|
||
| `sentFrom` | `'webapp'`, `'telegram-bot'`, `'cli'`, … | Origin of a user message. **`'cli'` marks CLI-echo traffic**: user/assistant text containing `<command-name>`, `<command-args>`, `<command-message>`, or `<local-command-stdout>` tags renders as a monospace *cli-output* block instead of a chat bubble, and a `<command-name>` block merges with its `<local-command-stdout>` follow-up (`web/src/chat/reducerCliOutput.ts`). |
|
||
| `deliveryMode` | `'queue'` \| `'steer'` | Durable delivery intent of a user send (see [pagination](./pagination.md#send-constraints)). Absent = `queue`. |
|
||
|
||
---
|
||
|
||
## Decode tree
|
||
|
||
```
|
||
DecryptedMessage.content
|
||
└─ unwrap envelope → { role, content: payload, meta? }
|
||
├─ role: 'user'
|
||
│ ├─ payload is string → user text
|
||
│ ├─ payload {type:'text', text, attachments?} → user text (+ attachments)
|
||
│ └─ anything else → user text (stringified payload)
|
||
└─ role: 'agent' — dispatch on payload.type
|
||
├─ 'codex' → generic agent family (payload.data.type dispatch)
|
||
├─ 'output' → Claude SDK passthrough + agy (payload.data.type dispatch)
|
||
├─ 'event' → AgentEvent union (payload.data)
|
||
└─ unknown → agent text (stringified payload)
|
||
```
|
||
|
||
---
|
||
|
||
## `role: 'user'` payloads
|
||
|
||
Reference: `web/src/chat/normalizeUser.ts`.
|
||
|
||
| Payload | Result |
|
||
|---|---|
|
||
| bare `string` | user text |
|
||
| `{type:'text', text: string, attachments?: AttachmentMetadata[]}` | user text with attachments. Each attachment is accepted only when `id`, `filename`, `mimeType` (strings), `size` (number), `path` (string) are all present; optional `previewUrl`. Invalid entries are skipped, an empty result means "no attachments". |
|
||
| anything else | user text = stringified payload (**never drop**) |
|
||
|
||
---
|
||
|
||
## `role: 'agent'` — family `'codex'`
|
||
|
||
`payload.type === 'codex'` (`AGENT_MESSAGE_PAYLOAD_TYPE`, `shared/src/modes.ts:8`) is the **generic agent envelope** used by the non-Claude-SDK flavors (codex, gemini, cursor, copilot, grok, opencode, pi, kimi; agy uses the `'output'` family below). Dispatch on `payload.data.type` (`web/src/chat/normalizeAgent.ts`):
|
||
|
||
| `data.type` | Payload fields | Renders as |
|
||
|---|---|---|
|
||
| `message` | `message: string`, `id?` (stream id), `streamSnapshot?` | Agent text. If the text is a bare JSON object with review markers (`findings` / `overall_correctness` / `overall_explanation`), parse it as a **codex review** block instead — unless it is a Pi stream snapshot (`streamSnapshot: true` or `id` matching `/^pi-.+-turn-\d+-message-\d+-text-\d+$/`), which is always plain text. |
|
||
| `reasoning` | `message: string`, `id?` | Reasoning (thinking) block. |
|
||
| `error` | `message: string` | Error event row. |
|
||
| `tool-call` | `callId`, `name?`, `input?`, `description?`, `nativeTitle?/title?`, `nativeKind?/kind?`, `progress?` | Open a tool card keyed by `callId`. |
|
||
| `tool-call-result` | `callId`, `output`, `is_error?` | Complete the tool card with the same `callId`. |
|
||
| `generated-image` | `imageId`/`image_id`, `fileName`/`file_name`, `mimeType`/`mime_type`, `id?`, `source?` | Inline generated image (fetch via the images REST endpoint). Missing `imageId` ⇒ drop. |
|
||
| `context_compacted` | `trigger?`, `preTokens`/`pre_tokens` | `compact` event row. |
|
||
| `compact-summary` | `summary`, `tokensBefore?`, `estimatedTokensAfter?` | `compact-summary` event row. |
|
||
| `token_count` | `info: {last \| total \| …}`, `thread_id?`, `scope?` | Usage sample (event). Prefer `info.last*` over `info.total*`; `context_tokens` falls back to `input_tokens`; `modelContextWindow` → `context_window`. Unparseable usage ⇒ drop. |
|
||
| `thread_goal_updated` | `goal {threadId, objective, status, tokenBudget?, tokensUsed?, timeUsedSeconds?, createdAt?, updatedAt?}`, `threadId?`, `turnId?` | `thread-goal-updated` event. `status` ∈ `active\|paused\|budgetLimited\|usageLimited\|blocked\|complete`; invalid goal ⇒ drop. |
|
||
| `thread_goal_cleared` | `threadId?` | `thread-goal-cleared` event. |
|
||
| `plan` | `entries`/`items`/`steps`: list of steps | Synthetic completed `update_plan` tool pair (cursor flavor). Steps accept `step\|content\|text\|title\|description` + `status\|state` (normalized to `pending\|in_progress\|completed`). Empty plan ⇒ drop. |
|
||
| `plan_update` | `plan`/`update`/`items`/`steps` | Same, codex flavor. |
|
||
| `agent-run-start` / `agent-run-update` / `agent-run-trace` | run payload | Background agent-run event rows (windowed separately, see [pagination](./pagination.md#client-windowing-normative-recommendation)). |
|
||
| *anything else* | — | **Drop silently** (`normalize.ts`: unknown codex content returns `null`, not a stringified bubble). |
|
||
|
||
Snake_case/camelCase field pairs above are both accepted — always probe both.
|
||
|
||
---
|
||
|
||
## `role: 'agent'` — family `'output'` (Claude SDK passthrough)
|
||
|
||
`payload.data` is a Claude Code SDK log entry, forwarded verbatim. Envelope-level fields on `data`: `uuid`, `parentUuid`, `isSidechain?`, `parentToolUseId?`, `timestamp?` (ISO-8601 execution-machine clock — parse to epoch ms, fall back to `createdAt`), and the flags below.
|
||
|
||
**Skip filters — evaluate first** (`isSkippableAgentContent` / `isClaudeChatVisibleMessage`):
|
||
|
||
- `data.isMeta` or `data.isCompactSummary` truthy ⇒ hidden.
|
||
- `data.type === 'rate_limit_event'` or `'tool_progress'` ⇒ hidden.
|
||
- `data.type === 'system'` with `subtype` **not** in `{api_error, turn_duration, microcompact_boundary, compact_boundary, away_summary}` ⇒ hidden.
|
||
- Empty `away_summary` / empty `agy_message` ⇒ hidden.
|
||
|
||
Then dispatch on `data.type`:
|
||
|
||
### `assistant`
|
||
|
||
`data.message` = `{model?, content, usage?}`. `content` is a string (⇒ one text block) or an array of blocks:
|
||
|
||
| Block | Fields | Renders as |
|
||
|---|---|---|
|
||
| `text` | `text` | agent text |
|
||
| `thinking` | `thinking` | reasoning |
|
||
| `tool_use` | `id`, `name`, `input` | tool card open (`description` convention: `input.description` when present) |
|
||
|
||
Other block types are ignored. `message.usage` carries `input_tokens`, `output_tokens`, `cache_creation_input_tokens?`, `cache_read_input_tokens?`, `service_tier?`, `context_window?`.
|
||
|
||
### `user`
|
||
|
||
Despite the name, these arrive through the agent path (tool results and system-injected turns). `data.message.content` cases:
|
||
|
||
| Case | Renders as |
|
||
|---|---|
|
||
| array with `tool_result` blocks `{tool_use_id, content, is_error?, permissions?}` | tool card completion. Prefer entry-level `data.toolUseResult` over the block's `content` when present. `permissions` = `{date, result:'approved'\|'denied', mode?, allowedTools?, decision?}` — merge into the tool card's permission state. |
|
||
| string content (any), or sidechain array-of-text | **sidechain marker** `{prompt}` — subagent prompts and system-injected turns; group under the parent Task tool card via `parentToolUseId` (fallback: exact prompt match). |
|
||
| non-sidechain array that is *entirely* `text` blocks | a real user message the CLI wrapped as output ⇒ render in the **user** lane. |
|
||
| `text` blocks mixed with tool results | agent text blocks. |
|
||
|
||
### `system` subtypes → event rows
|
||
|
||
| `subtype` | Fields | Event |
|
||
|---|---|---|
|
||
| `api_error` | `retryAttempt`, `maxRetries`, `error` | `api-error` |
|
||
| `turn_duration` | `durationMs`, `messageId?` | `turn-duration {durationMs, targetMessageId?}` |
|
||
| `microcompact_boundary` | `microcompactMetadata {trigger, preTokens, tokensSaved}` | `microcompact` |
|
||
| `compact_boundary` | `compactMetadata {trigger, preTokens}` | `compact` |
|
||
| `away_summary` | `content: string` | `recap {text}` |
|
||
|
||
### `summary`
|
||
|
||
`data.summary: string` ⇒ conversation-summary content block.
|
||
|
||
### agy (Antigravity) — also in the `'output'` family
|
||
|
||
| `data.type` | Renders as |
|
||
|---|---|
|
||
| `agy_message` | Agent text (`data.content`, per-turn `data.model?`). Empty ⇒ skip. Text starting `Inside the task-NNN log` ⇒ compact `AgyTaskLog` chip (synthetic completed tool pair). Echoed raw task results (`[Message] timestamp=…` trailer) are stripped from the prose. |
|
||
| `agy_tool_action` | Synthetic **completed** tool pair (tool-call + tool-result, same id — prefer `data.toolUseId`, falling back to the message id). `name === 'SYSTEM_MESSAGE'` ⇒ `AgyAsyncTask` background-task card; `name === 'ERROR_MESSAGE'` ⇒ `AgyError` card (`is_error: true`); otherwise map agy tool names to canonical ones (`run_command`→Bash, `view_file`→Read, `write_to_file`→Write, `replace_file_content`→Edit, `grep_search`→Grep, `list_dir`→LS) and translate arg keys (`CommandLine`→`command`, `TargetFile`→`file_path`, …), stripping agy's result preambles/trailers. See `normalizeAgent.ts` for the exact strip rules. |
|
||
|
||
### Unknown `'output'` types
|
||
|
||
A visible `data.type` not matched above ⇒ stringified agent text fallback (unlike the codex family, which drops).
|
||
|
||
---
|
||
|
||
## `role: 'agent'` — family `'event'`
|
||
|
||
`payload.data` is one `AgentEvent` (`web/src/chat/types.ts` lines 17–36). The union is **open-ended** — the last member is `{type: string} & Record<string, unknown>`; tolerate unknown types (render generically or ignore, never crash).
|
||
|
||
| `type` | Fields |
|
||
|---|---|
|
||
| `switch` | `mode: 'local' \| 'remote'` |
|
||
| `message` | `message: string` |
|
||
| `error` | `message: string` |
|
||
| `title-changed` | `title: string` |
|
||
| `limit-reached` | `endsAt: number`, `limitType: string` |
|
||
| `limit-warning` | `utilization: number` (0–1), `endsAt`, `limitType` |
|
||
| `ready` | — |
|
||
| `api-error` | `retryAttempt`, `maxRetries`, `error: unknown` |
|
||
| `turn-duration` | `durationMs`, `targetMessageId?` |
|
||
| `microcompact` | `trigger`, `preTokens`, `tokensSaved` |
|
||
| `compact` | `trigger`, `preTokens` |
|
||
| `compact-summary` | `summary`, `tokensBefore?`, `estimatedTokensAfter?` |
|
||
| `recap` | `text` |
|
||
| `thread-goal-updated` | `goal: ThreadGoal`, `threadId?`, `turnId?` |
|
||
| `thread-goal-cleared` | `threadId?` |
|
||
| `abort-restore` | `text` |
|
||
| *(catch-all)* | `{type: string, …}` |
|
||
|
||
Several event rows are also synthesized by the other two families (system subtypes, `context_compacted`, `token-count`, `agent-run-*`) — the renderer should treat them uniformly. Note: `event`-family `message` rows whose text is a `Goal …` status line are filtered by the **hub** at ingest and from REST pages (`isRedundantGoalStatusEventContent`, `shared/src/messages.ts`); clients need no special handling.
|
||
|
||
---
|
||
|
||
## Fallback rules
|
||
|
||
| Situation | Behavior |
|
||
|---|---|
|
||
| No unwrappable envelope | stringify whole `content` as agent text |
|
||
| `role` not `user`/`agent` | stringify `record.content` as agent text |
|
||
| user payload unrecognized | stringify as user text |
|
||
| `'codex'` family, unknown `data.type` | **drop** (return nothing) |
|
||
| `'output'` family, hidden by skip filters | **drop** |
|
||
| `'output'` family, visible but unknown `data.type` | stringify as agent text |
|
||
| `'event'` family, `data` lacks a string `type` | stringify as agent text |
|
||
|
||
"Stringify" = a stable JSON serialization (web: `safeStringify`) rendered as
|
||
plain text. Known event types also have the validation/empty-content skip
|
||
rules listed above (for example, an image without an ID or unparseable usage).
|
||
The golden fixtures and normalizer are authoritative; do not turn those
|
||
transport-only records into fallback chat bubbles.
|
||
|
||
---
|
||
|
||
## Truncation marker
|
||
|
||
At ingest the hub head+tail-truncates strings longer than `64 * 1024`
|
||
**UTF-16 code units** (`String.length`, not bytes) inside agent-role content
|
||
(`hub/src/store/contentCodec.ts`): first `48 * 1024` units +
|
||
`\n…[hapi: truncated N chars]…\n` + last `12 * 1024` units. User-role content
|
||
is never truncated (it is delivered verbatim to the CLI). The operation is
|
||
idempotent and applied deep (arrays/objects).
|
||
|
||
Clients must render truncated strings as-is (recognizing the `…[hapi: truncated N chars]…` marker is optional polish), must not assume tool results are complete, and must never choke on the marker.
|
||
|
||
---
|
||
|
||
## Permission requests are NOT messages
|
||
|
||
Pending tool approvals never appear in the message stream. They live on the session object (`shared/src/schemas.ts`):
|
||
|
||
```ts
|
||
session.agentState = {
|
||
requests?: Record<requestId, { tool: string, toolCallId?: string, arguments: unknown, createdAt?: number | null }>
|
||
completedRequests?: Record<requestId, {
|
||
tool, toolCallId?: string, arguments, createdAt?, completedAt?,
|
||
status: 'canceled' | 'denied' | 'approved' | 'resolved',
|
||
reason?, mode?, allowTools?: string[],
|
||
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort',
|
||
answers?: Record<string, string[]> // flat (AskUserQuestion)
|
||
| Record<string, { answers: string[] }> // nested (request_user_input)
|
||
}>
|
||
}
|
||
```
|
||
|
||
`agentState` updates arrive as a versioned SSE patch — apply it under the version gate described in [sse.md](./sse.md#versioned-patch-algorithm). Render pending `requests` as approval cards interleaved with the chat (the web reducer keys them to the matching `tool_use` when one exists); on resolution the entry moves to `completedRequests`, whose `status`/`answers` back-fill the tool card's permission state. Decide via `POST /api/sessions/:id/permissions/:requestId/approve` (`{mode?, allowTools?, decision?, answers?}`) or `…/deny` (`{decision?}`) — see [rest.md](./rest.md). Session-list badges come precomputed on `SessionSummary.pendingRequestsCount` / `pendingRequests` (≤ 5 entries).
|
||
|
||
`resolved` means native completion is known, but the winning answer/decision
|
||
is not. Render it neutrally; never infer approval or fill answers from an
|
||
unconfirmed local draft.
|
||
|
||
Correlate a permission with the transcript using **`entry.toolCallId ?? requestId`**,
|
||
but always submit approval/denial using **`requestId`**. Claude local-mode requests
|
||
use independent, one-shot reply IDs so stale responses cannot answer a later
|
||
request. The same identity rule applies to synthesized cards and completed
|
||
requests. A new pending reply takes precedence over an old completion for that
|
||
tool; a completion for the same reply ID takes precedence over its pending entry.
|
||
|
||
In local mode, submitting a web answer removes the actionable pending entry;
|
||
it need not immediately create a completion. Only Claude's native result confirms
|
||
which answer won the terminal/web race. Remote-wait expiration also removes the
|
||
pending entry without approving or denying the native prompt.
|
||
|
||
---
|
||
|
||
## Golden fixtures
|
||
|
||
The golden fixtures in `shared/fixtures/chat/` are the executable form of this section: input `DecryptedMessage` samples paired with the canonical decoded projection, generated from the web pipeline. A native decode implementation is correct when it reproduces them exactly — when the fixtures and this page disagree, the fixtures win.
|