# Ticket 1.3 — Event Translator

**Phase**: 1 — OpenCode Bridge (Core)
**Priority**: Critical (blocking)
**Depends on**: 0.2

## Description

Translate OpenCode's SSE event format into the WebSocket message format that the relay frontend expects. This is the mapping layer between OpenCode's **part-based event model** and claude-relay's established protocol. The frontend should not need to know it's talking to OpenCode instead of Claude Code.

OpenCode events have the shape `{ type: string, properties: object }`. The key events to translate are:

- `message.part.delta` — incremental text/reasoning streaming
- `message.part.updated` — part lifecycle changes (tool state, text finalization, etc.)
- `message.part.removed` — part deletion (cleanup, compaction)
- `message.updated` — message completion with cost/tokens
- `message.removed` — message deletion
- `session.status` — session idle/busy state
- `permission.asked/replied` — forwarded to permission bridge (ticket 1.5)
- `question.asked/replied/rejected` — forwarded to question bridge (ticket 1.6)
- `pty.created/updated/exited/deleted` — forwarded to terminal UI (ticket 4.5)
- `file.edited`, `file.watcher.updated` — forwarded to file browser (ticket 4.4)

### Tool Name Mapping

OpenCode uses lowercase tool names (`read`, `edit`, `bash`, etc.) while the frontend expects PascalCase (`Read`, `Edit`, `Bash`). The translator maps these:

```js
const TOOL_NAME_MAP = {
  read: "Read", edit: "Edit", write: "Write", bash: "Bash",
  glob: "Glob", grep: "Grep", webfetch: "WebFetch",
  websearch: "WebSearch", todowrite: "TodoWrite",
  todoread: "TodoRead", question: "AskUserQuestion",
};
// Unmapped tools (lsp, patch, skill, list) pass through to frontend's default renderer
```

## Acceptance Criteria

### AC1: Translates text streaming via part deltas

```gherkin
Given a message.part.delta event with field "text"
When the translator processes it
Then it emits { type: "delta", text: "<delta value>" }
And the text is the incremental content (not cumulative)
```

### AC2: Translates tool call lifecycle via part updates

```gherkin
Given a message.part.updated event where part.type is "tool" and part.state.status is "pending"
When the translator processes it
Then it emits { type: "tool_start", id: "<part.callID>", name: "<mapped tool name>" }

Given a message.part.updated event where part.type is "tool" and part.state.status is "running"
When the translator processes it
Then it emits { type: "tool_executing", id: "<part.callID>", name: "<mapped tool name>", input: <state.input> }

Given a message.part.updated event where part.type is "tool" and part.state.status is "completed"
When the translator processes it
Then it emits { type: "tool_result", id: "<part.callID>", content: "<state.output>", is_error: false }

Given a message.part.updated event where part.type is "tool" and part.state.status is "error"
When the translator processes it
Then it emits { type: "tool_result", id: "<part.callID>", content: "<state.error>", is_error: true }
```

### AC3: Maps tool names from lowercase to PascalCase

```gherkin
Given a tool part with tool name "read"
When the translator processes it
Then the emitted message uses name "Read"

Given a tool part with tool name "bash"
When the translator processes it
Then the emitted message uses name "Bash"

Given a tool part with tool name "lsp" (no mapping defined)
When the translator processes it
Then the emitted message uses the original name "lsp"
And the frontend's default renderer handles it
```

### AC4: Translates permission events

```gherkin
Given a permission.asked event
When the translator processes it
Then it emits:
  {
    type: "permission_request",
    requestId: "<properties.id>",
    toolName: "<properties.permission>",
    toolInput: { patterns: <properties.patterns>, metadata: <properties.metadata> },
    toolUseId: "<properties.tool.callID>" (if present)
  }
And all fields are mapped from OpenCode's schema
```

### AC5: Translates question/ask-user events

```gherkin
Given a question.asked event
When the translator processes it
Then it emits:
  {
    type: "ask_user",
    toolId: "<properties.id>",
    questions: properties.questions mapped to:
      [{
        question: "<text>",
        header: "<header>",
        options: [{ label, description }],
        multiSelect: <properties.questions[].multiple || false>,
        custom: <properties.questions[].custom ?? true>
      }]
  }
Note: OpenCode uses "multiple" not "multiSelect" — the translator maps this field name.
```

### AC6: Translates reasoning/thinking blocks

```gherkin
Given a message.part.updated event where part.type is "reasoning" (new part)
When the translator processes it
Then it emits { type: "thinking_start" }

Given a message.part.delta event on a reasoning part
When the translator processes it
Then it emits { type: "thinking_delta", text: "<delta>" }

Given a message.part.updated event where a reasoning part has time.end set (finalized)
When the translator processes it
Then it emits { type: "thinking_stop" }
```

### AC7: Translates usage and cost data

```gherkin
Given a message.updated event with an assistant message containing cost and tokens
When the translator processes it
Then it emits:
  {
    type: "result",
    usage: {
      input: <tokens.input>,
      output: <tokens.output>,
      cache_read: <tokens.cache.read>,
      cache_creation: <tokens.cache.write>
    },
    cost: <cost>,
    duration: <time.completed - time.created>,
    sessionId: "<sessionID>"
  }
```

### AC8: Translates session status changes

```gherkin
Given a session.status event with status.type "busy"
When the translator processes it
Then it emits { type: "status", status: "processing" }

Given a session.status event with status.type "idle"
When the translator processes it
Then it emits { type: "done", code: 0 }

Given a session.status event with status.type "retry"
When the translator processes it
Then it emits { type: "status", status: "processing" }
And it may emit an info message about the retry attempt
```

### AC9: Translates model information

```gherkin
Given model info is available (from message metadata or REST API)
When the translator processes it
Then it emits:
  {
    type: "model_info",
    model: "<modelID>",
    provider: "<providerID>",
    models: [{ id, name, provider }]
  }
```

### AC10: Translates part and message removal events

```gherkin
Given a message.part.removed event with { sessionID, messageID, partID }
When the translator processes it
Then it removes the part from internal tracking state
And it emits { type: "part_removed", partId: "<partID>", messageId: "<messageID>" }

Given a message.removed event with { sessionID, messageID }
When the translator processes it
Then it emits { type: "message_removed", messageId: "<messageID>" }
And it clears all tracked parts for that message
```

### AC11: Forwards PTY lifecycle events

```gherkin
Given a pty.created event with PTY info
When the translator processes it
Then it emits { type: "pty_created", pty: { id, title, command, cwd, status, pid } }

Given a pty.exited event with PTY info
When the translator processes it
Then it emits { type: "pty_exited", ptyId: "<id>", exitCode: <code> }

Given a pty.deleted event
When the translator processes it
Then it emits { type: "pty_deleted", ptyId: "<id>" }
```

### AC12: Forwards file change events

```gherkin
Given a file.edited event with { path, sessionID }
When the translator processes it
Then it emits { type: "file_changed", path: "<path>", changeType: "edited" }

Given a file.watcher.updated event with { path }
When the translator processes it
Then it emits { type: "file_changed", path: "<path>", changeType: "external" }
```

### AC13: Handles unknown event types gracefully

```gherkin
Given an OpenCode event with an unrecognised type
When the translator processes it
Then it logs the event type at debug level
And it does NOT emit anything to the frontend
And it does NOT throw an error
```

### AC14: Tracks part state for lifecycle translation

```gherkin
Given the translator needs to distinguish "new part" from "updated part"
When a message.part.updated event arrives
Then it checks an internal map of known part IDs
And if the part ID is new AND type is "reasoning", it emits thinking_start
And if the part ID is new AND type is "tool" with status "pending", it emits tool_start
And if the part ID was already seen, it emits the appropriate update/completion event
```

## Testable Components

| Component | Test Type | What to Assert |
|-----------|-----------|----------------|
| `translatePartDelta(event)` | Unit | Correct `{ type: "delta" }` for text, `{ type: "thinking_delta" }` for reasoning |
| `translatePartUpdated(event)` | Unit | Correct tool lifecycle messages based on part.type and state.status |
| `mapToolName(name)` | Unit | `read` → `Read`, `bash` → `Bash`, `lsp` → `lsp` (passthrough) |
| `translatePermission(event)` | Unit | Correct `{ type: "permission_request" }` with mapped fields |
| `translateQuestion(event)` | Unit | Correct `{ type: "ask_user" }` with `multiple` → `multiSelect` mapping |
| `translateReasoning(event)` | Unit | Correct start/delta/stop sequence based on part state |
| `translateMessageUpdated(event)` | Unit | Correct cost, token counts, session ID |
| `translateSessionStatus(event)` | Unit | Correct status values (busy→processing, idle→done) |
| `translateModelInfo(data)` | Unit | Correct model and provider info |
| `translatePartRemoved(event)` | Unit | Correct `{ type: "part_removed" }`, clears tracking |
| `translateMessageRemoved(event)` | Unit | Correct `{ type: "message_removed" }`, clears all part tracking for message |
| `translatePtyEvent(event)` | Unit | Correct pty_created/pty_exited/pty_deleted messages |
| `translateFileEvent(event)` | Unit | Correct `{ type: "file_changed" }` with changeType |
| `translateUnknown(event)` | Unit | Returns null, logs debug |
| `partStateTracking` | Unit | Correctly distinguishes new vs updated parts |
| Full stream translation | Snapshot | Sequence of OC events → correct sequence of relay messages (locked by snapshot) |

## API Shape

```ts
// src/lib/event-translator.ts
export function createTranslator() {
  // Returns a stateful translator that tracks part IDs for lifecycle detection
  // Internal state: Map<partID, { type, status }> for tracking seen parts

  return {
    translate(openCodeEvent),           // → RelayMessage | RelayMessage[] | null
    reset(),                            // Clear part tracking state (on session switch)
  };
}

// Also export individual translators and helpers for testing
export function mapToolName(name) { ... }
export function translatePartDelta(event) { ... }
export function translatePartUpdated(event, isNew) { ... }
export function translatePermission(event) { ... }
export function translateQuestion(event) { ... }
export function translateSessionStatus(event) { ... }
export function translateMessageUpdated(event) { ... }
```

### AC15: Recovers state after SSE reconnection

```gherkin
Given the SSE connection drops and the SSE consumer reconnects
When the translator receives a 'reconnected' signal from the SSE consumer
Then it resets its internal part tracking state (clears the seen-parts map)
And it requests the current session state via REST (GET /session/:id/message)
And it rebuilds its internal part map from the fetched message history
  (marking all existing parts as "seen" so they don't re-emit start events)
And subsequent SSE events are translated correctly without:
  - Duplicate thinking_start events for already-active reasoning blocks
  - Duplicate tool_start events for already-running tools
  - Missing lifecycle events for new parts that arrive post-reconnection
```

### AC16: Snapshot tests lock the translation contract

```gherkin
Given a recorded sequence of real OpenCode SSE events (captured from a live session)
When the translator processes this sequence
Then the output matches a committed snapshot file exactly
And if the translator logic changes, the snapshot test fails
And the developer must explicitly update the snapshot to confirm the change is intentional

The snapshot should cover:
  - A full message lifecycle (text streaming with deltas)
  - A tool execution lifecycle (pending → running → completed)
  - A reasoning/thinking block (start → deltas → stop)
  - A permission request event
  - A question event
  - A session status transition (busy → idle)
  - A part removal event
```

## Testable Components (continued)

| Component | Test Type | What to Assert |
|-----------|-----------|----------------|
| `rebuildStateFromHistory(messages)` | Unit | Correctly populates part map from REST response |
| `onReconnect(sessionId)` | Unit | Resets state, fetches history, rebuilds map |
| Reconnect + new events | Fixture | Reconnect → rebuild → receive new events → no duplicate lifecycle emissions |
| Full stream translation | Snapshot | Sequence of OC events → correct sequence of relay messages (locked by snapshot) |

## Notes

The translator is **stateful** because OpenCode's part-based model requires tracking which parts have been seen before. For example:
- A `message.part.updated` for a reasoning part could be the first time we see it (→ emit `thinking_start`) or an update (→ no emission, wait for delta).
- A `message.part.updated` for a tool part transitioning from `pending` → `running` → `completed` requires knowing the previous state.

The translator state should be reset when switching sessions.

### State Recovery on Reconnection

When the SSE connection drops and reconnects, the translator's in-memory part-tracking state becomes stale. A simple `reset()` is insufficient because:
- Parts that were mid-lifecycle before the drop would re-emit `thinking_start` or `tool_start` events
- The frontend would see duplicate lifecycle events, breaking the UI

The correct approach is: reset the state, fetch the current session's message history via REST (`GET /session/:id/message`), and rebuild the part map from the fetched data. This way, only truly new events post-reconnection trigger lifecycle emissions. See AC15.

## Reference

- Claude-relay WebSocket protocol: `../claude-relay/lib/project.js` (broadcast messages)
- Claude-relay SDK message processing: `../claude-relay/lib/sdk-bridge.js` (processSDKMessage)
- OpenCode event bus: `../opencode/packages/opencode/src/bus/` (event definitions with Zod schemas)
- OpenCode message parts: `../opencode/packages/opencode/src/session/message-v2.ts`
- OpenCode tool state machine: pending → running → completed | error
- OpenCode Part types: text, reasoning, tool, file, snapshot, patch, agent, compaction, subtask, retry, step-start, step-finish
- This is the MOST important file for maintaining frontend compatibility
