12 KiB
Session Protocol Implementation
Overview
Implement docs/session-protocol.md as the new message format for Codex sessions in the CLI, and add client-side support in happy-app to parse, normalize, and render these messages alongside existing legacy formats (output, codex, acp).
Key decisions from planning:
- CLI (Codex only): Emit session-protocol events instead of current codex/acp format to the server
- App: Support both legacy and session-protocol formats (detect and handle both)
- Conversion happens on CLI level; client normalizes the flat event stream back into grouped structures (subagent nesting, turn grouping)
Context
Message flow today (Codex → App)
runCodex.tsreceives MCP messages from Codex CLI- Calls
session.sendCodexMessage()which wraps in{ role: 'agent', content: { type: 'codex', data: body } } - Encrypted and sent via WebSocket
- App decrypts →
normalizeRawMessage()intypesRaw.ts→NormalizedMessage reducer.tsprocessesNormalizedMessage[]→Message[]for UI
What changes
- CLI: Instead of
sendCodexMessage(), use a newsendSessionProtocolMessage()that emits the 7 event types from session-protocol.md - App:
typesRaw.tsgets a new discriminated union branchtype: 'session'inrawAgentRecordSchema, withnormalizeRawMessage()converting session-protocol events toNormalizedMessage - Reducer: Minimal changes — already handles
NormalizedMessagecorrectly; just needs turn tracking awareness
Files involved
CLI (happy-cli):
src/api/apiSession.ts— newsendSessionProtocolMessage()methodsrc/codex/runCodex.ts— convert fromsendCodexMessage()to session-protocol eventssrc/codex/utils/reasoningProcessor.ts— emit thinking events
App (happy-app):
sources/sync/typesRaw.ts— new Zod schema for session-protocol envelope + events, new normalizer branchsources/sync/reducer/reducer.ts— turn tracking (optional, for grouping)sources/sync/reducer/messageToEvent.ts— handle session-protocolturn-start/turn-end
Development Approach
- Testing approach: Regular (code first, then tests)
- Complete each task fully before moving to the next
- Make small, focused changes
- CRITICAL: every task MUST include new/updated tests for code changes in that task
- CRITICAL: all tests must pass before starting next task
- CRITICAL: update this plan file when scope changes during implementation
- Run tests after each change
- Maintain backward compatibility (legacy formats keep working)
Testing Strategy
- Unit tests: required for every task
- Tests live colocated with source files (
.test.tssuffix) - Framework: Vitest
Progress Tracking
- Mark completed items with
[x]immediately when done - Add newly discovered tasks with ➕ prefix
- Document issues/blockers with ⚠️ prefix
- ⚠️
packages/happy-cliandpackages/happy-appdo not define alintscript; verification used full test suites plusyarn typecheckin both packages. - ⚠️ In app normalization for
content.type === 'session',uuiduses envelopeid(notturn) to keep message identity unique whileinvokehandles sidechain linkage. - ⚠️
ReasoningProcessorandDiffProcessorstill emit legacy internal shapes; Codex now maps those outputs to session-protocol envelopes insessionProtocolMapper.tsbefore sending.
Implementation Steps
Task 1: Define session-protocol types and Zod schemas (shared)
Create the TypeScript types and Zod schemas for all 7 session-protocol event types plus the envelope. These will be used by both CLI (for emitting) and app (for parsing).
- Create
packages/happy-cli/src/sessionProtocol/types.tswith:- Envelope type:
{ id, time, role, turn?, invoke?, ev } - Event union type discriminated by
ev.t:text,tool-call-start,tool-call-end,file,photo,turn-start,turn-end - Each event type as a separate interface
- Zod schemas for validation
- Envelope type:
- Export a helper
createEnvelope(role, ev, opts?)that generates cuid2 id and timestamp - Write tests for Zod schema validation (valid events pass, invalid rejected)
- Write tests for
createEnvelopehelper - Run tests — must pass before next task
Task 2: Add sendSessionProtocolMessage() to apiSession.ts
Add a new send method that wraps session-protocol envelopes in the wire format.
- Add
sendSessionProtocolMessage(envelope)toApiSessionClientinpackages/happy-cli/src/api/apiSession.ts- Wraps as
{ role: 'session', content: envelope } - Encrypts and sends via socket (same pattern as
sendCodexMessage)
- Wraps as
- Write tests for the new method (verify envelope wrapping, encryption call)
- Run tests — must pass before next task
Task 3: Convert Codex message handler to emit session-protocol events
Replace sendCodexMessage() calls in runCodex.ts with session-protocol event emission.
- Add turn tracking state to
runCodex.ts:currentTurnId: string | null— set ontask_started, cleared ontask_complete/turn_aborted- Emit
turn-startevent ontask_started - Emit
turn-endevent ontask_complete/turn_aborted
- Convert
agent_message→textevent withturnfield - Convert
agent_reasoning/agent_reasoning_delta→textevent withthinking: true - Convert
exec_command_begin/exec_approval_request→tool-call-starteventcall: usecall_idfrom MCP messagename:CodexBashtitle: short summary from commanddescription: full command descriptionargs: input params
- Convert
exec_command_end→tool-call-endevent - Convert
patch_apply_begin→tool-call-startwith nameCodexPatch - Convert
patch_apply_end→tool-call-end - Convert
token_count→ skip (no session-protocol equivalent, or emit as-is using sendCodexMessage for backwards compat) - Keep
task_started/task_complete/turn_abortedsending via session events (sendSessionEvent) — these are lifecycle, not messages - Write tests for the conversion logic (mock session, verify correct event types emitted)
- Run tests — must pass before next task
Task 4: Update ReasoningProcessor to emit session-protocol events
The ReasoningProcessor currently calls session.sendCodexMessage() directly via callback. Update it.
- Update the callback type in
ReasoningProcessorconstructor to accept session-protocol envelopes - Convert reasoning tool-call emissions to
tool-call-start/tool-call-endsession-protocol events - Convert reasoning message emissions to
textevents withthinking: true - Write tests for the updated processor output format
- Run tests — must pass before next task
Task 5: Add session-protocol parsing to typesRaw.ts (app)
Add a new type: 'session' branch to the raw record schema and update normalizeRawMessage().
- Add Zod schema for the session-protocol envelope in
typesRaw.ts:z.object({ type: z.literal('session'), data: sessionEnvelopeSchema })- The envelope schema validates
id,time,role,turn?,invoke?,evwith discriminated union onev.t
- Add
'session'torawAgentRecordSchemadiscriminated union - Add normalization logic in
normalizeRawMessage()forraw.content.type === 'session':ev.t === 'text'→NormalizedMessagewithrole: 'agent', content:[{ type: 'text', text, uuid, parentUUID }](ortype: 'thinking'ifev.thinking)ev.t === 'tool-call-start'→NormalizedMessagewith content:[{ type: 'tool-call', id: ev.call, name: ev.name, input: ev.args, description: ev.description }]ev.t === 'tool-call-end'→NormalizedMessagewith content:[{ type: 'tool-result', tool_use_id: ev.call, content: null, is_error: false }]ev.t === 'turn-start'→NormalizedMessagewithrole: 'event', content:{ type: 'message', message: 'Turn started' }(or skip)ev.t === 'turn-end'→NormalizedMessagewithrole: 'event', content:{ type: 'ready' }(triggers ready handling)ev.t === 'file'→ map to tool-call for displayev.t === 'file'withimagemetadata → map to tool-call for display (or new message type later)
- Handle
invokefield: setparentUUIDto theinvokevalue so sidechains work through existing tracer - Handle
turnfield: setuuidtoturnvalue so grouping works - Write tests for each event type normalization (valid input → correct NormalizedMessage)
- Write tests for invalid/malformed session events (graceful null return)
- Run tests — must pass before next task
Task 6: Update reducer for turn-start/turn-end awareness
The reducer already handles NormalizedMessage well. Minor updates for the new event semantics.
- Ensure
turn-endevents with{ type: 'ready' }triggerhasReadyEvent = true(already works via existing code path) - Ensure
turn-startevents don't create visible messages (filter them out or make them no-op) - Ensure subagent messages (with
invoke→parentUUID) flow through existing sidechain/tracer logic - Write tests verifying turn lifecycle events flow correctly through reducer
- Write tests verifying subagent messages nest correctly under parent tool calls
- Run tests — must pass before next task
Task 7: Verify acceptance criteria
- Verify Codex CLI emits session-protocol events for all message types
- Verify app can parse and display session-protocol messages
- Verify app still handles legacy formats (output, codex, acp) correctly
- Verify subagent nesting works via invoke field
- Verify turn lifecycle (turn-start/turn-end) works correctly
- Run full test suite —
yarn testin happy-cli, happy-app - Run linter — all issues must be fixed
Task 8: [Final] Update documentation
- Update
docs/session-protocol.mdif any deviations from spec were necessary - Add inline code comments for the session-protocol conversion path
Technical Details
Envelope structure (wire format)
// What gets encrypted and sent over WebSocket
{
role: 'agent',
content: {
type: 'session', // NEW discriminator
data: { // session-protocol envelope
id: 'cuid2...',
time: 1739347200000,
role: 'agent',
turn: 'turn-id',
invoke: 'parent-call-id', // only for subagents
ev: { t: 'text', text: 'Hello' }
}
},
meta: { sentFrom: 'cli' }
}
Event → NormalizedMessage mapping
| Session Protocol Event | NormalizedMessage role | NormalizedMessage content type |
|---|---|---|
text |
agent |
text (or thinking if ev.thinking) |
tool-call-start |
agent |
tool-call |
tool-call-end |
agent |
tool-result |
turn-start |
event |
{ type: 'message', message: 'Turn started' } |
turn-end |
event |
{ type: 'ready' } |
file |
agent |
tool-call (synthetic, for UI display) |
photo |
agent |
tool-call (synthetic, for UI display) |
Turn tracking in CLI
task_started → emit turn-start, set currentTurnId
agent_message → emit text with turn: currentTurnId
exec_command_begin → emit tool-call-start with turn: currentTurnId
exec_command_end → emit tool-call-end with turn: currentTurnId
task_complete → emit turn-end, clear currentTurnId
Post-Completion
Manual verification:
- Test with real Codex session to verify messages display correctly in app
- Verify existing Claude Code sessions still display correctly (legacy format)
- Test abort/resume flow with session-protocol format
- Test permission flow with session-protocol format