docs/plans/session-protocol-impl.md
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:
runCodex.ts receives MCP messages from Codex CLIsession.sendCodexMessage() which wraps in { role: 'agent', content: { type: 'codex', data: body } }normalizeRawMessage() in typesRaw.ts → NormalizedMessagereducer.ts processes NormalizedMessage[] → Message[] for UIsendCodexMessage(), use a new sendSessionProtocolMessage() that emits the 7 event types from session-protocol.mdtypesRaw.ts gets a new discriminated union branch type: 'session' in rawAgentRecordSchema, with normalizeRawMessage() converting session-protocol events to NormalizedMessageNormalizedMessage correctly; just needs turn tracking awarenessCLI (happy-cli):
src/api/apiSession.ts — new sendSessionProtocolMessage() methodsrc/codex/runCodex.ts — convert from sendCodexMessage() to session-protocol eventssrc/codex/utils/reasoningProcessor.ts — emit thinking eventsApp (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-protocol turn-start/turn-end.test.ts suffix)[x] immediately when donepackages/happy-cli and packages/happy-app do not define a lint script; verification used full test suites plus yarn typecheck in both packages.content.type === 'session', uuid uses envelope id (not turn) to keep message identity unique while invoke handles sidechain linkage.ReasoningProcessor and DiffProcessor still emit legacy internal shapes; Codex now maps those outputs to session-protocol envelopes in sessionProtocolMapper.ts before sending.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).
packages/happy-cli/src/sessionProtocol/types.ts with:
{ id, time, role, turn?, invoke?, ev }ev.t: text, tool-call-start, tool-call-end, file, photo, turn-start, turn-endcreateEnvelope(role, ev, opts?) that generates cuid2 id and timestampcreateEnvelope helpersendSessionProtocolMessage() to apiSession.tsAdd a new send method that wraps session-protocol envelopes in the wire format.
sendSessionProtocolMessage(envelope) to ApiSessionClient in packages/happy-cli/src/api/apiSession.ts
{ role: 'session', content: envelope }sendCodexMessage)Replace sendCodexMessage() calls in runCodex.ts with session-protocol event emission.
runCodex.ts:
currentTurnId: string | null — set on task_started, cleared on task_complete/turn_abortedturn-start event on task_startedturn-end event on task_complete / turn_abortedagent_message → text event with turn fieldagent_reasoning / agent_reasoning_delta → text event with thinking: trueexec_command_begin / exec_approval_request → tool-call-start event
call: use call_id from MCP messagename: CodexBashtitle: short summary from commanddescription: full command descriptionargs: input paramsexec_command_end → tool-call-end eventpatch_apply_begin → tool-call-start with name CodexPatchpatch_apply_end → tool-call-endtoken_count → skip (no session-protocol equivalent, or emit as-is using sendCodexMessage for backwards compat)task_started/task_complete/turn_aborted sending via session events (sendSessionEvent) — these are lifecycle, not messagesThe ReasoningProcessor currently calls session.sendCodexMessage() directly via callback. Update it.
ReasoningProcessor constructor to accept session-protocol envelopestool-call-start / tool-call-end session-protocol eventstext events with thinking: truetypesRaw.ts (app)Add a new type: 'session' branch to the raw record schema and update normalizeRawMessage().
typesRaw.ts:
z.object({ type: z.literal('session'), data: sessionEnvelopeSchema })id, time, role, turn?, invoke?, ev with discriminated union on ev.t'session' to rawAgentRecordSchema discriminated unionnormalizeRawMessage() for raw.content.type === 'session':
ev.t === 'text' → NormalizedMessage with role: 'agent', content: [{ type: 'text', text, uuid, parentUUID }] (or type: 'thinking' if ev.thinking)ev.t === 'tool-call-start' → NormalizedMessage with content: [{ type: 'tool-call', id: ev.call, name: ev.name, input: ev.args, description: ev.description }]ev.t === 'tool-call-end' → NormalizedMessage with content: [{ type: 'tool-result', tool_use_id: ev.call, content: null, is_error: false }]ev.t === 'turn-start' → NormalizedMessage with role: 'event', content: { type: 'message', message: 'Turn started' } (or skip)ev.t === 'turn-end' → NormalizedMessage with role: 'event', content: { type: 'ready' } (triggers ready handling)ev.t === 'file' → map to tool-call for displayev.t === 'file' with image metadata → map to tool-call for display (or new message type later)invoke field: set parentUUID to the invoke value so sidechains work through existing tracerturn field: set uuid to turn value so grouping worksThe reducer already handles NormalizedMessage well. Minor updates for the new event semantics.
turn-end events with { type: 'ready' } trigger hasReadyEvent = true (already works via existing code path)turn-start events don't create visible messages (filter them out or make them no-op)invoke → parentUUID) flow through existing sidechain/tracer logicyarn test in happy-cli, happy-appdocs/session-protocol.md if any deviations from spec were necessary// 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' }
}
| 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) |
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
Manual verification: