# Parser Gotchas & Edge Cases

> **Date**: 2026-02-24
> **Source**: JSONL spike analysis of 461 files / 91,306 entries

---

## 1. Streaming Dedup (CRITICAL)

Uma resposta API = N linhas JSONL (observado até 20). Todas partilham o mesmo `message.id` e `requestId`.

```
Entry 0: content=[text(2 chars)]       stop_reason=null   usage={...identical...}
Entry 1: content=[thinking(311 chars)]  stop_reason=null   usage={...identical...}
Entry 2: content=[text(54 chars)]       stop_reason=null   usage={...identical...}
Entry 3: content=[tool_use(AskUser)]    stop_reason=null   usage={...identical...}
```

**Impacto por métrica:**

| Métrica | Dedup necessário? | Estratégia |
|---------|-------------------|------------|
| Tokens/Cost | SIM — usage duplicado | Agrupar por `message.id`, contar usage 1x |
| Tool usage | SIM — tool_use espalhado | Juntar content blocks de todas entries do mesmo message.id |
| Turn count | NÃO — contar entries `type: "user"` | Cada user msg é 1 entry |
| Turn duration | NÃO — é entry system separada | 1 entry `system:turn_duration` por turn |

**O nosso dedup actual** (`message.id:requestId` → keep first) está correcto para custos. Para tool counting, precisamos de juntar content blocks.

---

## 2. permissionMode está nos User Entries

O campo `permissionMode` ("default" | "acceptEdits" | "bypassPermissions") aparece nas entradas `type: "user"`, NÃO nas `type: "assistant"`.

**Consequência**: para saber se um turn do assistant foi em YOLO mode, temos que olhar para a entry `user` que o precedeu (ou a primeira user entry da sessão que define o modo).

**Estratégia**: ao parsear uma sessão, manter estado `current_permission_mode` que muda quando vemos uma user entry com `permissionMode` diferente.

---

## 3. stop_reason é null em Streaming

Em entries de streaming, `stop_reason` é `null` — mesmo na última entry. Só 2 valores observados: `"tool_use"` e `"stop_sequence"`.

**Consequência**: para saber se o model parou para chamar uma tool ou terminou naturalmente, verificar se há um content block `tool_use` na mensagem completa (após juntar streaming entries).

---

## 4. Modelo `<synthetic>` em Compact Summaries

Compact summaries usam `model: "<synthetic>"`. Já filtramos isto no parser actual (`model.startswith("<")`), mas é bom documentar porquê.

Entries com `isCompactSummary: true` não representam API calls reais — são texto gerado localmente pelo Claude Code para resumir a conversa.

---

## 5. Subagent vs Main Session

| Aspecto | Main Session | Subagent |
|---------|-------------|----------|
| Primeira entry | `file-history-snapshot` | `user` (tool_result) |
| `agentId` | ausente | presente (e.g., "a4682d9") |
| `isSidechain` | `false` | `true` |
| `sessionId` | = filename | = parent session UUID |
| Path | `<uuid>.jsonl` | `<uuid>/subagents/agent-a<hash>.jsonl` |

**Gotcha**: subagent `sessionId` aponta para o parent, não para si próprio. Para identificar o subagent, usar o `agentId` ou o path do ficheiro.

**Compact subagents**: ficheiros `agent-acompact-<hash>.jsonl` são subagents que foram compactados. Começam com um tool_result do parent + prompt de compact.

---

## 6. Content Blocks são Incrementais

Entries de streaming têm content blocks **incrementais**, não cumulativos. A entry 0 tem `[text]`, a entry 1 tem `[thinking]`, etc.

Para reconstruir a mensagem completa: concatenar `content[]` de todas as entries do mesmo `message.id`.

**Isto é relevante para tool counting** — um tool_use pode aparecer na entry 3 de 5. Se só olharmos para a primeira entry (como fazemos agora para dedup de custos), perdemos tools.

---

## 7. turn_duration é Entry Separada

`system:turn_duration` é uma entry própria com `durationMs`, não um campo no assistant message.

```json
{
  "type": "system",
  "subtype": "turn_duration",
  "durationMs": 45230
}
```

**Linkage**: ligar ao turn via `parentUuid` ou por proximidade temporal (é a entry imediatamente após o assistant turn).

---

## 8. toolUseResult — Zona Cinzenta

O campo `toolUseResult` nas user entries tem metadata útil MAS também conteúdo sensível:

**Seguro extrair:**
- `numFiles`, `numLines`, `numMatches` (Grep/Glob/Read)
- `is_error`, `interrupted` (Bash)
- `durationMs` (Write, Task agent)
- `totalDurationMs` (Task agent completion)
- `truncated` (Write)
- `success` (TaskUpdate, Skill)
- `appliedLimit` (Glob)

**NUNCA extrair:**
- `stdout`, `stderr` (Bash)
- `content` (Read, Grep — actual file contents)
- `filePath`, `filenames` (paths podem revelar estrutura do projecto)
- `oldString`, `newString` (Edit — actual code changes)
- `query`, `results` (WebSearch — search queries)
- `questions`, `answers` (AskUserQuestion — user responses)

---

## 9. Cache Breakdown por Duração

O campo `usage.cache_creation` tem breakdown por duração:
```json
"cache_creation": {
  "ephemeral_5m_input_tokens": 0,
  "ephemeral_1h_input_tokens": 1740
}
```

Actualmente ignoramos isto e usamos só `cache_creation_input_tokens` (o total). Mas o breakdown por duração pode ser interessante para cache analytics avançados.

---

## 10. Zero Parse Errors

91,306 entries, 461 ficheiros, 0 erros de JSON. O formato é rock-solid. Não precisamos de try/except defensivo em excesso — mas mantemos por boa prática.

---

## 11. Entradas de User Interruption

Quando o user cancela (Ctrl+C), aparece:
```json
{"type": "user", "message": {"content": [{"type": "text", "text": "[Request interrupted by user]"}]}}
```

Isto conta como um "turn" mas não tem resposta do assistant. Considerar filtrar para métricas de turn count/duration.

---

## 12. API Errors com Retry Exponencial

Entries `system:api_error` mostram retry pattern com backoff:
```
507ms → 1224ms → 2216ms → 4461ms → 8307ms → 16637ms
```

Campos: `retryInMs`, `retryAttempt`, `maxRetries` (sempre 10).

Para error rate metrics: contar estas entries. Para latência: a duração do retry afecta o turn_duration total.
